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

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

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

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

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

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

View File

@@ -0,0 +1,33 @@
'use client';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ForgotPasswordForm } from '@/components/auth';
export default function ForgotPasswordPage() {
const router = useRouter();
return (
<div className="min-h-screen bg-base flex flex-col">
<header className="p-4">
<Link href="/" className="inline-flex items-center gap-2 text-lg font-semibold text-primary">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-accent font-bold">G</span>
</div>
GooSeek
</Link>
</header>
<main className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<div className="bg-elevated border border-border rounded-2xl p-8 shadow-xl">
<ForgotPasswordForm
showTitle
onBack={() => router.push('/login')}
/>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export default function AuthLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { LoginForm } from '@/components/auth';
import { useAuth } from '@/lib/contexts/AuthContext';
export default function LoginPage() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen bg-base flex items-center justify-center">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-base flex flex-col">
<header className="p-4">
<Link href="/" className="inline-flex items-center gap-2 text-lg font-semibold text-primary">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-accent font-bold">G</span>
</div>
GooSeek
</Link>
</header>
<main className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<div className="bg-elevated border border-border rounded-2xl p-8 shadow-xl">
<LoginForm
showTitle
onSuccess={() => router.push('/')}
onSwitchToRegister={() => router.push('/register')}
/>
</div>
<p className="text-center text-sm text-muted mt-6">
Продолжая, вы соглашаетесь с{' '}
<Link href="/terms" className="text-accent hover:text-accent-hover">
условиями
</Link>{' '}
и{' '}
<Link href="/privacy" className="text-accent hover:text-accent-hover">
политикой конфиденциальности
</Link>
</p>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { RegisterForm } from '@/components/auth';
import { useAuth } from '@/lib/contexts/AuthContext';
export default function RegisterPage() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen bg-base flex items-center justify-center">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-base flex flex-col">
<header className="p-4">
<Link href="/" className="inline-flex items-center gap-2 text-lg font-semibold text-primary">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-accent font-bold">G</span>
</div>
GooSeek
</Link>
</header>
<main className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<div className="bg-elevated border border-border rounded-2xl p-8 shadow-xl">
<RegisterForm
showTitle
onSuccess={() => router.push('/')}
onSwitchToLogin={() => router.push('/login')}
/>
</div>
<p className="text-center text-sm text-muted mt-6">
Уже есть аккаунт?{' '}
<Link href="/login" className="text-accent hover:text-accent-hover font-medium">
Войти
</Link>
</p>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useState, FormEvent, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Lock, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import { resetPassword } from '@/lib/auth';
function ResetPasswordContent() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const isPasswordValid = password.length >= 8;
const doPasswordsMatch = password === confirmPassword && confirmPassword.length > 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Недействительная ссылка для сброса пароля');
return;
}
if (!isPasswordValid) {
setError('Пароль должен содержать минимум 8 символов');
return;
}
if (!doPasswordsMatch) {
setError('Пароли не совпадают');
return;
}
setIsLoading(true);
try {
await resetPassword({ token, newPassword: password });
setIsSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка сброса пароля');
} finally {
setIsLoading(false);
}
};
if (!token) {
return (
<div className="text-center py-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-error/10 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-error" />
</div>
<h2 className="text-xl font-semibold text-primary mb-2">Недействительная ссылка</h2>
<p className="text-secondary mb-6">
Ссылка для сброса пароля недействительна или устарела.
</p>
<Link
href="/forgot-password"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-medium rounded-xl transition-colors"
>
Запросить новую ссылку
</Link>
</div>
);
}
if (isSuccess) {
return (
<div className="text-center py-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h2 className="text-xl font-semibold text-primary mb-2">Пароль изменён</h2>
<p className="text-secondary mb-6">
Ваш пароль успешно изменён. Теперь вы можете войти с новым паролем.
</p>
<button
onClick={() => router.push('/login')}
className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-medium rounded-xl transition-colors"
>
Войти
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Новый пароль</h1>
<p className="text-secondary mt-2">Введите новый пароль для вашего аккаунта</p>
</div>
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-secondary mb-2">
Новый пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="new-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full pl-11 pr-11 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-secondary transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-secondary mb-2">
Подтвердите пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="confirm-new-password"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Сохранение...
</>
) : (
'Сохранить новый пароль'
)}
</button>
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-base flex flex-col">
<header className="p-4">
<Link href="/" className="inline-flex items-center gap-2 text-lg font-semibold text-primary">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-accent font-bold">G</span>
</div>
GooSeek
</Link>
</header>
<main className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<div className="bg-elevated border border-border rounded-2xl p-8 shadow-xl">
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-accent" />
</div>
}>
<ResetPasswordContent />
</Suspense>
</div>
</div>
</main>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -414,6 +414,16 @@ body {
background: hsl(240 4% 28%);
}
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
::selection {
background: hsl(239 84% 67% / 0.3);
}
@@ -678,3 +688,172 @@ body {
.animate-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
/* =====================
Computer UI Styles
===================== */
/* Action step container */
.action-step {
@apply relative;
}
.action-step::before {
content: '';
position: absolute;
left: 1.25rem;
top: 2.75rem;
bottom: 0;
width: 2px;
background: linear-gradient(
to bottom,
hsl(var(--border)) 0%,
transparent 100%
);
}
/* Strikethrough animation for completed tasks */
@keyframes strikethrough {
from {
width: 0;
}
to {
width: 100%;
}
}
.task-completed {
position: relative;
}
.task-completed::after {
content: '';
position: absolute;
left: 0;
top: 50%;
height: 1px;
background: hsl(var(--text-muted));
animation: strikethrough 0.3s ease-out forwards;
}
/* Typing indicator */
@keyframes typing-dot {
0%, 20% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.typing-indicator span {
animation: typing-dot 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
/* Code highlight colors */
.code-keyword { color: #c792ea; }
.code-string { color: #c3e88d; }
.code-comment { color: #546e7a; font-style: italic; }
.code-function { color: #82aaff; }
.code-number { color: #f78c6c; }
.code-operator { color: #89ddff; }
/* Thinking block pulse */
@keyframes thinking-pulse {
0%, 100% {
background: hsl(239 84% 67% / 0.05);
}
50% {
background: hsl(239 84% 67% / 0.12);
}
}
.thinking-active {
animation: thinking-pulse 2s ease-in-out infinite;
}
/* Status indicator pulse */
@keyframes status-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.status-active {
animation: status-pulse 1.5s ease-in-out infinite;
}
/* File card hover effect */
.file-card-hover {
transition: all 0.2s ease;
}
.file-card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.3);
}
/* Collapsible animation */
.collapsible-content {
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
}
/* Progress bar shimmer */
@keyframes progress-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.progress-shimmer {
background: linear-gradient(
90deg,
hsl(239 84% 67%) 0%,
hsl(260 90% 75%) 25%,
hsl(187 85% 65%) 50%,
hsl(260 90% 75%) 75%,
hsl(239 84% 67%) 100%
);
background-size: 200% 100%;
animation: progress-shimmer 2s linear infinite;
}
/* Event timeline line */
.timeline-line {
position: relative;
}
.timeline-line::before {
content: '';
position: absolute;
left: 0.5rem;
top: 1.5rem;
bottom: 0;
width: 1px;
background: linear-gradient(
to bottom,
hsl(var(--border)) 0%,
transparent 100%
);
}
/* Artifact card glow on hover */
.artifact-glow:hover {
box-shadow:
0 0 20px hsl(142 71% 45% / 0.15),
0 4px 12px rgba(0, 0, 0, 0.2);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'GooSeek - AI Search',
@@ -13,7 +14,9 @@ export default function RootLayout({
}) {
return (
<html lang="ru" className="dark">
<body className="antialiased">{children}</body>
<body className="antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { ReactNode } from 'react';
import { LanguageProvider } from '@/lib/contexts/LanguageContext';
import { AuthProvider } from '@/lib/contexts/AuthContext';
import { AuthModal } from '@/components/auth';
export function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<LanguageProvider>
{children}
<AuthModal />
</LanguageProvider>
</AuthProvider>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
import { useState, useRef, useCallback, KeyboardEvent, useEffect, ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Send,
@@ -13,17 +13,35 @@ import {
Globe,
Image as ImageIcon,
ArrowUp,
X,
FileText,
File,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
type Mode = 'speed' | 'balanced' | 'quality';
export interface ChatAttachment {
id: string;
file: File;
type: 'file' | 'image';
preview?: string;
}
export interface SendOptions {
mode: Mode;
webSearch: boolean;
attachments: ChatAttachment[];
}
interface ChatInputProps {
onSend: (message: string, mode: Mode) => void;
onSend: (message: string, options: SendOptions) => void;
onStop?: () => void;
isLoading?: boolean;
placeholder?: string;
autoFocus?: boolean;
value?: string;
onChange?: (value: string) => void;
}
const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] = [
@@ -32,21 +50,81 @@ const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] =
{ value: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
];
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }: ChatInputProps) {
const [message, setMessage] = useState('');
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus, value: externalValue, onChange: externalOnChange }: ChatInputProps) {
const [internalMessage, setInternalMessage] = useState('');
const [mode, setMode] = useState<Mode>('balanced');
const [isFocused, setIsFocused] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const isControlled = externalValue !== undefined;
const message = isControlled ? externalValue : internalMessage;
const setMessage = isControlled ? (externalOnChange || (() => {})) : setInternalMessage;
useEffect(() => {
if (textareaRef.current && message) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 180)}px`;
}
}, [message]);
const handleSubmit = useCallback(() => {
if (message.trim() && !isLoading) {
onSend(message.trim(), mode);
setMessage('');
if ((message.trim() || attachments.length > 0) && !isLoading) {
onSend(message.trim(), {
mode,
webSearch: webSearchEnabled,
attachments,
});
if (isControlled && externalOnChange) {
externalOnChange('');
} else {
setInternalMessage('');
}
setAttachments([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}, [message, mode, isLoading, onSend]);
}, [message, mode, webSearchEnabled, attachments, isLoading, onSend, isControlled, externalOnChange]);
const handleFileSelect = useCallback((e: ChangeEvent<HTMLInputElement>, type: 'file' | 'image') => {
const files = e.target.files;
if (!files) return;
const newAttachments: ChatAttachment[] = Array.from(files).map((file) => {
const attachment: ChatAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
file,
type,
};
if (type === 'image' && file.type.startsWith('image/')) {
attachment.preview = URL.createObjectURL(file);
}
return attachment;
});
setAttachments((prev) => [...prev, ...newAttachments]);
e.target.value = '';
}, []);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => {
const attachment = prev.find((a) => a.id === id);
if (attachment?.preview) {
URL.revokeObjectURL(attachment.preview);
}
return prev.filter((a) => a.id !== id);
});
}, []);
const toggleWebSearch = useCallback(() => {
setWebSearchEnabled((prev) => !prev);
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -78,6 +156,44 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
${isFocused ? 'shadow-glow-sm' : ''}
`}
>
{/* Attachments Preview */}
{attachments.length > 0 && (
<div className="px-4 pt-3 flex flex-wrap gap-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="relative group flex items-center gap-2 bg-surface/80 border border-border/50 rounded-lg px-3 py-2"
>
{attachment.type === 'image' && attachment.preview ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-8 h-8 object-cover rounded"
/>
) : (
<div className="w-8 h-8 flex items-center justify-center bg-accent/10 rounded">
{attachment.file.type.includes('pdf') ? (
<FileText className="w-4 h-4 text-accent" />
) : (
<File className="w-4 h-4 text-accent" />
)}
</div>
)}
<span className="text-xs text-secondary max-w-[120px] truncate">
{attachment.file.name}
</span>
<button
onClick={() => removeAttachment(attachment.id)}
className="ml-1 p-0.5 rounded-full hover:bg-error/20 text-muted hover:text-error transition-colors"
title="Удалить"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Main Input Area */}
<div className="flex items-end gap-3 p-4 pb-3">
<textarea
@@ -107,16 +223,16 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
) : (
<button
onClick={handleSubmit}
disabled={!message.trim()}
disabled={!message.trim() && attachments.length === 0}
className={`
w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200
${message.trim()
${(message.trim() || attachments.length > 0)
? 'btn-gradient'
: 'bg-surface/50 text-muted border border-border/50'
}
`}
>
<ArrowUp className={`w-5 h-5 ${message.trim() ? 'btn-gradient-text' : ''}`} />
<ArrowUp className={`w-5 h-5 ${(message.trim() || attachments.length > 0) ? 'btn-gradient-text' : ''}`} />
</button>
)}
</div>
@@ -165,23 +281,47 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
{/* Action Buttons */}
<button
className="btn-icon w-8 h-8"
title="Веб-поиск"
className={`btn-icon w-8 h-8 ${webSearchEnabled ? 'bg-accent/20 text-accent border-accent/40' : ''}`}
title={webSearchEnabled ? 'Веб-поиск включён' : 'Включить веб-поиск'}
onClick={toggleWebSearch}
type="button"
>
<Globe className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
className={`btn-icon w-8 h-8 ${attachments.some(a => a.type === 'file') ? 'text-accent' : ''}`}
title="Прикрепить файл"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<Paperclip className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Изображение"
className={`btn-icon w-8 h-8 ${attachments.some(a => a.type === 'image') ? 'text-accent' : ''}`}
title="Прикрепить изображение"
onClick={() => imageInputRef.current?.click()}
type="button"
>
<ImageIcon className="w-4 h-4" />
</button>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
accept=".pdf,.doc,.docx,.txt,.csv,.json,.xml,.md"
onChange={(e) => handleFileSelect(e, 'file')}
/>
<input
ref={imageInputRef}
type="file"
className="hidden"
multiple
accept="image/*"
onChange={(e) => handleFileSelect(e, 'image')}
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -14,31 +14,43 @@ import {
ChevronRight,
TrendingUp,
BookOpen,
Cpu,
Plane,
HeartPulse,
X,
Shield,
LogIn,
UserPlus,
} from 'lucide-react';
import { filterMenuItems } from '@/lib/config/menu';
import { useAuth } from '@/lib/contexts/AuthContext';
interface SidebarProps {
onClose?: () => void;
}
const navItems = [
const allNavItems = [
{ href: '/', icon: Search, label: 'Поиск' },
{ href: '/discover', icon: Compass, label: 'Discover' },
{ href: '/spaces', icon: FolderOpen, label: 'Пространства' },
{ href: '/history', icon: Clock, label: 'История' },
];
const toolItems = [
{ href: '/computer', icon: Cpu, label: 'Computer' },
const allToolItems = [
{ href: '/travel', icon: Plane, label: 'Путешествия' },
{ href: '/medicine', icon: HeartPulse, label: 'Медицина' },
{ href: '/finance', icon: TrendingUp, label: 'Финансы' },
{ href: '/learning', icon: BookOpen, label: 'Обучение' },
];
const navItems = filterMenuItems(allNavItems);
const toolItems = filterMenuItems(allToolItems);
export function Sidebar({ onClose }: SidebarProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const { user, isAuthenticated, showAuthModal } = useAuth();
const isMobile = !!onClose;
const isAdmin = user?.role === 'admin';
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
@@ -131,7 +143,17 @@ export function Sidebar({ onClose }: SidebarProps) {
</nav>
{/* Footer */}
<div className="p-3 border-t border-border/30">
<div className="p-3 border-t border-border/30 space-y-1">
{isAdmin && (
<NavLink
href="/admin"
icon={Shield}
label="Админка"
collapsed={!isMobile && collapsed}
active={pathname.startsWith('/admin')}
onClick={handleNavClick}
/>
)}
<NavLink
href="/settings"
icon={Settings}
@@ -140,6 +162,79 @@ export function Sidebar({ onClose }: SidebarProps) {
active={pathname === '/settings'}
onClick={handleNavClick}
/>
{/* Auth buttons for non-authenticated users */}
{!isAuthenticated && (
<div className={`pt-2 ${collapsed && !isMobile ? 'px-0' : ''}`}>
{collapsed && !isMobile ? (
<button
onClick={() => showAuthModal('login')}
className="w-full h-10 flex items-center justify-center rounded-xl bg-accent/10 text-accent hover:bg-accent/20 transition-all"
>
<LogIn className="w-[18px] h-[18px]" />
</button>
) : (
<div className="space-y-2">
<button
onClick={() => { showAuthModal('login'); handleNavClick(); }}
className="w-full h-10 flex items-center justify-center gap-2 rounded-xl bg-surface/50 text-secondary hover:text-primary hover:bg-surface transition-all text-sm font-medium"
>
<LogIn className="w-4 h-4" />
Войти
</button>
<button
onClick={() => { showAuthModal('register'); handleNavClick(); }}
className="w-full h-10 flex items-center justify-center gap-2 rounded-xl bg-accent text-white hover:bg-accent-hover transition-all text-sm font-medium"
>
<UserPlus className="w-4 h-4" />
Регистрация
</button>
</div>
)}
</div>
)}
{/* User info for authenticated users */}
{isAuthenticated && user && (
<div className={`pt-2 ${collapsed && !isMobile ? 'justify-center' : ''}`}>
{collapsed && !isMobile ? (
<Link
href="/settings"
className="w-full h-10 flex items-center justify-center rounded-xl hover:bg-surface/50 transition-all"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</Link>
) : (
<Link
href="/settings"
onClick={handleNavClick}
className="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-surface/50 transition-all"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-primary truncate">{user.name}</div>
<div className="text-xs text-muted truncate">{user.email}</div>
</div>
</Link>
)}
</div>
)}
</div>
</motion.aside>
);

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { LoginForm } from './LoginForm';
import { RegisterForm } from './RegisterForm';
export function AuthModal() {
const { authModalMode, hideAuthModal, showAuthModal } = useAuth();
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
hideAuthModal();
}
},
[hideAuthModal]
);
useEffect(() => {
if (authModalMode) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [authModalMode, handleKeyDown]);
if (!authModalMode) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={hideAuthModal}
/>
<div className="relative z-10 w-full max-w-md mx-4 animate-scale-in">
<div className="bg-elevated border border-border rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-primary">
{authModalMode === 'login' ? 'Вход' : 'Регистрация'}
</h2>
<button
onClick={hideAuthModal}
className="p-2 rounded-lg text-muted hover:text-secondary hover:bg-surface/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6">
{authModalMode === 'login' ? (
<LoginForm
onSuccess={hideAuthModal}
onSwitchToRegister={() => showAuthModal('register')}
/>
) : (
<RegisterForm
onSuccess={hideAuthModal}
onSwitchToLogin={() => showAuthModal('login')}
/>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Loader2, ArrowLeft, CheckCircle } from 'lucide-react';
import { forgotPassword } from '@/lib/auth';
interface ForgotPasswordFormProps {
onBack?: () => void;
showTitle?: boolean;
}
export function ForgotPasswordForm({ onBack, showTitle = false }: ForgotPasswordFormProps) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await forgotPassword(email);
setIsSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка отправки');
} finally {
setIsLoading(false);
}
};
if (isSuccess) {
return (
<div className="text-center py-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h2 className="text-xl font-semibold text-primary mb-2">Письмо отправлено</h2>
<p className="text-secondary mb-6">
Если аккаунт с email <span className="text-primary font-medium">{email}</span> существует,
вы получите письмо с инструкциями по сбросу пароля.
</p>
{onBack && (
<button
onClick={onBack}
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Вернуться ко входу
</button>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Сброс пароля</h1>
<p className="text-secondary mt-2">
Введите email и мы отправим ссылку для сброса пароля
</p>
</div>
)}
{!showTitle && (
<p className="text-secondary text-sm">
Введите email, связанный с вашим аккаунтом. Мы отправим ссылку для сброса пароля.
</p>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div>
<label htmlFor="reset-email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reset-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Отправка...
</>
) : (
'Отправить ссылку'
)}
</button>
{onBack && (
<button
type="button"
onClick={onBack}
className="w-full py-3 px-4 text-secondary hover:text-primary font-medium
rounded-xl transition-all duration-200 flex items-center justify-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Вернуться ко входу
</button>
)}
</form>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Lock, Loader2, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface LoginFormProps {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
showTitle?: boolean;
}
export function LoginForm({ onSuccess, onSwitchToRegister, showTitle = false }: LoginFormProps) {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ email, password });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Вход в GooSeek</h1>
<p className="text-secondary mt-2">Войдите в свой аккаунт</p>
</div>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary mb-2">
Пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
className="w-full pl-11 pr-11 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-secondary transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 rounded border-border bg-surface text-accent focus:ring-accent/30"
/>
<span className="text-sm text-secondary">Запомнить меня</span>
</label>
<a href="/forgot-password" className="text-sm text-accent hover:text-accent-hover transition-colors">
Забыли пароль?
</a>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Вход...
</>
) : (
'Войти'
)}
</button>
{onSwitchToRegister && (
<p className="text-center text-sm text-secondary">
Нет аккаунта?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-accent hover:text-accent-hover font-medium transition-colors"
>
Зарегистрироваться
</button>
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Lock, User, Loader2, Eye, EyeOff, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface RegisterFormProps {
onSuccess?: () => void;
onSwitchToLogin?: () => void;
showTitle?: boolean;
}
export function RegisterForm({ onSuccess, onSwitchToLogin, showTitle = false }: RegisterFormProps) {
const { register } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const passwordRequirements = [
{ met: password.length >= 8, text: 'Минимум 8 символов' },
{ met: /[A-Z]/.test(password), text: 'Заглавная буква' },
{ met: /[a-z]/.test(password), text: 'Строчная буква' },
{ met: /[0-9]/.test(password), text: 'Цифра' },
];
const isPasswordValid = password.length >= 8;
const doPasswordsMatch = password === confirmPassword && confirmPassword.length > 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!isPasswordValid) {
setError('Пароль должен содержать минимум 8 символов');
return;
}
if (!doPasswordsMatch) {
setError('Пароли не совпадают');
return;
}
setIsLoading(true);
try {
await register({ email, password, name });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка регистрации');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Регистрация</h1>
<p className="text-secondary mt-2">Создайте аккаунт в GooSeek</p>
</div>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary mb-2">
Имя
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
autoComplete="name"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-secondary mb-2">
Пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reg-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full pl-11 pr-11 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-secondary transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-1">
{passwordRequirements.map((req, i) => (
<div
key={i}
className={`flex items-center gap-1.5 text-xs ${
req.met ? 'text-success' : 'text-muted'
}`}
>
{req.met ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{req.text}
</div>
))}
</div>
)}
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-secondary mb-2">
Подтвердите пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className={`w-full pl-11 pr-11 py-3 bg-surface/50 border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:ring-1 transition-all duration-200
${
confirmPassword.length > 0
? doPasswordsMatch
? 'border-success/50 focus:border-success/50 focus:ring-success/20'
: 'border-error/50 focus:border-error/50 focus:ring-error/20'
: 'border-border focus:border-accent/50 focus:ring-accent/20'
}`}
/>
{confirmPassword.length > 0 && (
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{doPasswordsMatch ? (
<Check className="w-5 h-5 text-success" />
) : (
<X className="w-5 h-5 text-error" />
)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<input
id="terms"
type="checkbox"
required
className="w-4 h-4 mt-0.5 rounded border-border bg-surface text-accent focus:ring-accent/30"
/>
<label htmlFor="terms" className="text-sm text-secondary">
Я согласен с{' '}
<a href="/terms" className="text-accent hover:text-accent-hover transition-colors">
условиями использования
</a>{' '}
и{' '}
<a href="/privacy" className="text-accent hover:text-accent-hover transition-colors">
политикой конфиденциальности
</a>
</label>
</div>
<button
type="submit"
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Регистрация...
</>
) : (
'Создать аккаунт'
)}
</button>
{onSwitchToLogin && (
<p className="text-center text-sm text-secondary">
Уже есть аккаунт?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-accent hover:text-accent-hover font-medium transition-colors"
>
Войти
</button>
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { User, Settings, LogOut, CreditCard, Shield, ChevronDown } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import Link from 'next/link';
export function UserMenu() {
const { user, logout, showAuthModal } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLogout = async () => {
setIsOpen(false);
await logout();
};
if (!user) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => showAuthModal('login')}
className="px-4 py-2 text-sm font-medium text-secondary hover:text-primary transition-colors"
>
Войти
</button>
<button
onClick={() => showAuthModal('register')}
className="px-4 py-2 text-sm font-medium text-white bg-accent hover:bg-accent-hover rounded-xl transition-colors"
>
Регистрация
</button>
</div>
);
}
const tierBadge = {
free: { label: 'Free', className: 'bg-surface text-secondary' },
pro: { label: 'Pro', className: 'bg-accent/20 text-accent' },
business: { label: 'Business', className: 'bg-success/20 text-success' },
}[user.tier];
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-surface/50 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-primary">{user.name}</div>
<div className="text-xs text-muted">{user.email}</div>
</div>
<ChevronDown className={`w-4 h-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-64 bg-elevated border border-border rounded-xl shadow-xl overflow-hidden z-50 animate-scale-in">
<div className="p-3 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover rounded-full" />
) : (
<span className="text-base font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-primary truncate">{user.name}</div>
<div className="text-xs text-muted truncate">{user.email}</div>
</div>
<span className={`px-2 py-0.5 text-xs font-medium rounded-md ${tierBadge.className}`}>
{tierBadge.label}
</span>
</div>
</div>
<div className="py-1">
<Link
href="/settings"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<Settings className="w-4 h-4" />
Настройки
</Link>
<Link
href="/settings/billing"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
Подписка
</Link>
{user.role === 'admin' && (
<Link
href="/admin"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<Shield className="w-4 h-4" />
Админ-панель
</Link>
)}
</div>
<div className="border-t border-border py-1">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-error hover:bg-error/10 transition-colors"
>
<LogOut className="w-4 h-4" />
Выйти
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { AuthModal } from './AuthModal';
export { LoginForm } from './LoginForm';
export { RegisterForm } from './RegisterForm';
export { ForgotPasswordForm } from './ForgotPasswordForm';
export { UserMenu } from './UserMenu';

View File

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

View File

@@ -0,0 +1,695 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Rss,
TrendingUp,
DollarSign,
Cloud,
Github,
Mail,
Send,
Bell,
Radio,
Check,
X,
ChevronRight,
Loader2,
AlertCircle,
ExternalLink,
Eye,
EyeOff,
} from 'lucide-react';
import type { ConnectorId, ConnectorConfig, ConnectorField, UserConnector, ConnectorCategory } from '@/lib/types';
const CONNECTORS: ConnectorConfig[] = [
{
id: 'web_search',
name: 'Web Search',
description: 'Поиск по интернету через SearXNG',
icon: 'search',
category: 'search',
requiresAuth: false,
fields: [],
actions: ['source'],
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
description: 'Приватный поиск без отслеживания',
icon: 'search',
category: 'search',
requiresAuth: false,
fields: [],
actions: ['source'],
},
{
id: 'rss',
name: 'RSS Feeds',
description: 'Подписка на RSS/Atom ленты',
icon: 'rss',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'feeds',
label: 'URL лент (по одной на строку)',
type: 'textarea',
placeholder: 'https://habr.com/ru/rss/\nhttps://example.com/feed.xml',
required: true,
helpText: 'Введите URL RSS или Atom лент',
},
],
actions: ['source'],
},
{
id: 'coincap',
name: 'CoinCap',
description: 'Криптовалюты в реальном времени',
icon: 'trending',
category: 'finance',
requiresAuth: false,
fields: [
{
key: 'assets',
label: 'Активы для отслеживания',
type: 'text',
placeholder: 'bitcoin, ethereum, solana',
required: false,
helpText: 'Через запятую. Пусто = все популярные',
},
],
actions: ['source'],
},
{
id: 'exchangerate',
name: 'ExchangeRate',
description: 'Курсы валют в реальном времени',
icon: 'dollar',
category: 'finance',
requiresAuth: false,
fields: [
{
key: 'baseCurrency',
label: 'Базовая валюта',
type: 'text',
placeholder: 'USD',
required: false,
helpText: 'По умолчанию USD',
},
{
key: 'currencies',
label: 'Отслеживаемые валюты',
type: 'text',
placeholder: 'EUR, RUB, GBP',
required: false,
helpText: 'Через запятую',
},
],
actions: ['source'],
},
{
id: 'openmeteo',
name: 'Open-Meteo',
description: 'Погода без API ключа',
icon: 'cloud',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'latitude',
label: 'Широта',
type: 'text',
placeholder: '55.7558',
required: false,
helpText: 'Координаты для прогноза',
},
{
key: 'longitude',
label: 'Долгота',
type: 'text',
placeholder: '37.6173',
required: false,
},
{
key: 'city',
label: 'Или название города',
type: 'text',
placeholder: 'Москва',
required: false,
helpText: 'Будет определено автоматически',
},
],
actions: ['source'],
},
{
id: 'github',
name: 'GitHub',
description: 'Репозитории, issues, релизы',
icon: 'github',
category: 'data',
requiresAuth: true,
fields: [
{
key: 'token',
label: 'Personal Access Token',
type: 'password',
placeholder: 'ghp_xxxxxxxxxxxx',
required: true,
helpText: 'Создайте в Settings → Developer settings → Personal access tokens',
},
{
key: 'repos',
label: 'Репозитории для отслеживания',
type: 'textarea',
placeholder: 'owner/repo\nowner2/repo2',
required: false,
helpText: 'По одному на строку. Пусто = ваши репозитории',
},
],
actions: ['source'],
},
{
id: 'email',
name: 'Email (SMTP)',
description: 'Отправка уведомлений на почту',
icon: 'mail',
category: 'notifications',
requiresAuth: true,
fields: [
{
key: 'host',
label: 'SMTP сервер',
type: 'text',
placeholder: 'smtp.gmail.com',
required: true,
},
{
key: 'port',
label: 'Порт',
type: 'number',
placeholder: '587',
required: true,
},
{
key: 'user',
label: 'Email / Логин',
type: 'text',
placeholder: 'your@email.com',
required: true,
},
{
key: 'password',
label: 'Пароль приложения',
type: 'password',
placeholder: '••••••••',
required: true,
helpText: 'Для Gmail используйте App Password',
},
{
key: 'to',
label: 'Получатель',
type: 'text',
placeholder: 'notify@email.com',
required: true,
helpText: 'Куда отправлять уведомления',
},
],
actions: ['notify'],
},
{
id: 'telegram',
name: 'Telegram Bot',
description: 'Уведомления в Telegram',
icon: 'send',
category: 'notifications',
requiresAuth: true,
fields: [
{
key: 'botToken',
label: 'Bot Token',
type: 'password',
placeholder: '123456789:ABCdefGHI...',
required: true,
helpText: 'Получите у @BotFather',
},
{
key: 'chatId',
label: 'Chat ID',
type: 'text',
placeholder: '123456789',
required: true,
helpText: 'Ваш ID или ID группы. Узнайте у @userinfobot',
},
],
actions: ['notify'],
},
{
id: 'push',
name: 'Push (браузер)',
description: 'Уведомления в браузере',
icon: 'bell',
category: 'notifications',
requiresAuth: false,
fields: [],
actions: ['notify'],
},
{
id: 'websocket',
name: 'WebSocket',
description: 'Подключение к WebSocket потокам',
icon: 'radio',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'url',
label: 'WebSocket URL',
type: 'url',
placeholder: 'wss://stream.example.com/v1/data',
required: true,
},
{
key: 'headers',
label: 'Заголовки (JSON)',
type: 'textarea',
placeholder: '{"Authorization": "Bearer xxx"}',
required: false,
helpText: 'Опционально, в формате JSON',
},
],
actions: ['source'],
},
];
const CATEGORY_LABELS: Record<ConnectorCategory, string> = {
search: 'Поиск',
data: 'Данные',
finance: 'Финансы',
notifications: 'Уведомления',
};
const CATEGORY_ORDER: ConnectorCategory[] = ['search', 'data', 'finance', 'notifications'];
function getIcon(iconName: string) {
const icons: Record<string, React.ElementType> = {
search: Search,
rss: Rss,
trending: TrendingUp,
dollar: DollarSign,
cloud: Cloud,
github: Github,
mail: Mail,
send: Send,
bell: Bell,
radio: Radio,
};
return icons[iconName] || Search;
}
interface ConnectorsSettingsProps {
onSave?: (connectors: UserConnector[]) => void;
}
export function ConnectorsSettings({ onSave }: ConnectorsSettingsProps) {
const [userConnectors, setUserConnectors] = useState<UserConnector[]>([]);
const [expandedId, setExpandedId] = useState<ConnectorId | null>(null);
const [saving, setSaving] = useState<ConnectorId | null>(null);
const [testing, setTesting] = useState<ConnectorId | null>(null);
useEffect(() => {
const saved = localStorage.getItem('userConnectors');
if (saved) {
try {
setUserConnectors(JSON.parse(saved));
} catch {
// ignore
}
}
}, []);
const getUserConnector = useCallback((id: ConnectorId): UserConnector | undefined => {
return userConnectors.find((c) => c.id === id);
}, [userConnectors]);
const updateConnector = useCallback((id: ConnectorId, updates: Partial<UserConnector>) => {
setUserConnectors((prev) => {
const existing = prev.find((c) => c.id === id);
if (existing) {
return prev.map((c) => (c.id === id ? { ...c, ...updates } : c));
}
return [...prev, { id, enabled: false, config: {}, status: 'disconnected', ...updates }];
});
}, []);
const handleToggle = useCallback((id: ConnectorId, enabled: boolean) => {
const connector = CONNECTORS.find((c) => c.id === id);
if (!connector) return;
if (enabled && connector.requiresAuth) {
const userConn = getUserConnector(id);
const hasRequiredFields = connector.fields
.filter((f) => f.required)
.every((f) => userConn?.config[f.key]);
if (!hasRequiredFields) {
setExpandedId(id);
return;
}
}
updateConnector(id, {
enabled,
status: enabled ? 'connected' : 'disconnected',
connectedAt: enabled ? new Date().toISOString() : undefined,
});
}, [getUserConnector, updateConnector]);
const handleSaveConfig = useCallback(async (id: ConnectorId, config: Record<string, string>) => {
setSaving(id);
await new Promise((r) => setTimeout(r, 500));
updateConnector(id, {
config,
enabled: true,
status: 'connected',
connectedAt: new Date().toISOString(),
});
setSaving(null);
setExpandedId(null);
const updated = userConnectors.map((c) =>
c.id === id ? { ...c, config, enabled: true, status: 'connected' as const } : c
);
localStorage.setItem('userConnectors', JSON.stringify(updated));
onSave?.(updated);
}, [userConnectors, updateConnector, onSave]);
const handleTest = useCallback(async (id: ConnectorId) => {
setTesting(id);
await new Promise((r) => setTimeout(r, 1500));
updateConnector(id, { status: 'connected' });
setTesting(null);
}, [updateConnector]);
const handleDisconnect = useCallback((id: ConnectorId) => {
updateConnector(id, {
enabled: false,
status: 'disconnected',
config: {},
});
const updated = userConnectors.filter((c) => c.id !== id);
localStorage.setItem('userConnectors', JSON.stringify(updated));
onSave?.(updated);
}, [userConnectors, updateConnector, onSave]);
const groupedConnectors = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
connectors: CONNECTORS.filter((c) => c.category === category),
}));
return (
<div className="space-y-6">
{groupedConnectors.map(({ category, label, connectors }) => (
<div key={category}>
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
{category === 'search' && <Search className="w-3.5 h-3.5" />}
{category === 'data' && <Rss className="w-3.5 h-3.5" />}
{category === 'finance' && <TrendingUp className="w-3.5 h-3.5" />}
{category === 'notifications' && <Bell className="w-3.5 h-3.5" />}
{label}
</h3>
<div className="space-y-2">
{connectors.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
userConnector={getUserConnector(connector.id)}
expanded={expandedId === connector.id}
onExpand={() => setExpandedId(expandedId === connector.id ? null : connector.id)}
onToggle={(enabled) => handleToggle(connector.id, enabled)}
onSave={(config) => handleSaveConfig(connector.id, config)}
onTest={() => handleTest(connector.id)}
onDisconnect={() => handleDisconnect(connector.id)}
saving={saving === connector.id}
testing={testing === connector.id}
/>
))}
</div>
</div>
))}
</div>
);
}
interface ConnectorCardProps {
connector: ConnectorConfig;
userConnector?: UserConnector;
expanded: boolean;
onExpand: () => void;
onToggle: (enabled: boolean) => void;
onSave: (config: Record<string, string>) => void;
onTest: () => void;
onDisconnect: () => void;
saving: boolean;
testing: boolean;
}
function ConnectorCard({
connector,
userConnector,
expanded,
onExpand,
onToggle,
onSave,
onTest,
onDisconnect,
saving,
testing,
}: ConnectorCardProps) {
const [formData, setFormData] = useState<Record<string, string>>(userConnector?.config || {});
const [showPasswords, setShowPasswords] = useState<Record<string, boolean>>({});
const Icon = getIcon(connector.icon);
const isConnected = userConnector?.enabled && userConnector?.status === 'connected';
const hasError = userConnector?.status === 'error';
const needsConfig = connector.requiresAuth || connector.fields.length > 0;
useEffect(() => {
if (userConnector?.config) {
setFormData(userConnector.config);
}
}, [userConnector?.config]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
const togglePasswordVisibility = (key: string) => {
setShowPasswords((prev) => ({ ...prev, [key]: !prev[key] }));
};
return (
<motion.div
layout
className={`rounded-xl border transition-colors ${
isConnected
? 'bg-emerald-500/5 border-emerald-500/20'
: hasError
? 'bg-red-500/5 border-red-500/20'
: 'bg-elevated/40 border-border/40'
}`}
>
<button
onClick={needsConfig ? onExpand : () => onToggle(!isConnected)}
className="w-full flex items-center gap-3 p-3 sm:p-4"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
isConnected ? 'bg-emerald-500/10' : 'bg-surface/60'
}`}>
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-400' : 'text-secondary'}`} />
</div>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{connector.name}</span>
{isConnected && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
Подключён
</span>
)}
{hasError && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
Ошибка
</span>
)}
{!connector.requiresAuth && connector.fields.length === 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface/60 text-muted">
Без настройки
</span>
)}
</div>
<p className="text-xs text-muted mt-0.5 truncate">{connector.description}</p>
</div>
{needsConfig ? (
<ChevronRight className={`w-4 h-4 text-muted transition-transform ${expanded ? 'rotate-90' : ''}`} />
) : (
<div className={`w-10 h-6 rounded-full relative transition-colors ${
isConnected ? 'bg-emerald-500' : 'bg-surface/80 border border-border'
}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full transition-all ${
isConnected ? 'right-1 bg-white' : 'left-1 bg-secondary'
}`} />
</div>
)}
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<form onSubmit={handleSubmit} className="px-4 pb-4 pt-2 border-t border-border/30">
<div className="space-y-3">
{connector.fields.map((field) => (
<div key={field.key}>
<label className="block text-xs text-secondary mb-1.5">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
<div className="relative">
{field.type === 'textarea' ? (
<textarea
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
rows={3}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 resize-none"
/>
) : (
<input
type={field.type === 'password' && !showPasswords[field.key] ? 'password' : 'text'}
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 pr-10"
/>
)}
{field.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-secondary"
>
{showPasswords[field.key] ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
)}
</div>
{field.helpText && (
<p className="text-[10px] text-muted mt-1">{field.helpText}</p>
)}
</div>
))}
</div>
{hasError && userConnector?.errorMessage && (
<div className="mt-3 p-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-400">{userConnector.errorMessage}</p>
</div>
)}
<div className="flex items-center gap-2 mt-4">
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Сохранение...
</>
) : (
<>
<Check className="w-4 h-4" />
{isConnected ? 'Обновить' : 'Подключить'}
</>
)}
</button>
{isConnected && (
<>
<button
type="button"
onClick={onTest}
disabled={testing}
className="px-3 py-2 bg-surface/60 border border-border/50 text-secondary text-sm rounded-lg hover:bg-surface hover:text-primary transition-colors disabled:opacity-50"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Тест'
)}
</button>
<button
type="button"
onClick={onDisconnect}
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" />
</button>
</>
)}
</div>
{connector.id === 'github' && (
<a
href="https://github.com/settings/tokens/new"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
>
Создать токен на GitHub
<ExternalLink className="w-3 h-3" />
</a>
)}
{connector.id === 'telegram' && (
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
>
Открыть @BotFather
<ExternalLink className="w-3 h-3" />
</a>
)}
</form>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default ConnectorsSettings;

View File

@@ -9,12 +9,18 @@ import type {
TopMovers,
Lesson,
LessonStep,
ComputerTask,
ComputerExecuteRequest,
ComputerTaskEvent,
ComputerModel,
ComputerConnector,
Artifact,
AdminUser,
AdminUserCreateRequest,
AdminUserUpdateRequest,
AdminPost,
AdminPostCreateRequest,
AdminPostUpdateRequest,
PlatformSettings,
FeatureFlags,
DiscoverCategory,
DiscoverSource,
DashboardStats,
AuditLog,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
@@ -81,9 +87,28 @@ export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEv
}
}
export async function fetchDiscover(topic = 'tech', region = 'russia'): Promise<DiscoverItem[]> {
export interface DiscoverResponse {
blogs: DiscoverItem[];
hasMore: boolean;
page: number;
total?: number;
}
export async function fetchDiscover(
topic = 'tech',
region = 'russia',
page = 1,
limit = 10
): Promise<DiscoverResponse> {
const params = new URLSearchParams({
topic,
region,
page: String(page),
limit: String(limit),
});
const response = await fetch(
`${API_BASE}/api/v1/discover?topic=${encodeURIComponent(topic)}&region=${encodeURIComponent(region)}`,
`${API_BASE}/api/v1/discover?${params}`,
{ headers: getAuthHeaders() }
);
@@ -92,7 +117,12 @@ export async function fetchDiscover(topic = 'tech', region = 'russia'): Promise<
}
const data = await response.json();
return data.blogs || [];
return {
blogs: data.blogs || [],
hasMore: data.hasMore ?? false,
page: data.page ?? page,
total: data.total,
};
}
export async function fetchConfig(): Promise<{ values: Record<string, unknown> }> {
@@ -392,187 +422,341 @@ export async function deleteLesson(id: string): Promise<void> {
}
}
// =====================
// Computer API (Perplexity Computer style)
// =====================
export async function fetchAdminDashboard(): Promise<DashboardStats> {
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
headers: getAuthHeaders(),
});
export async function executeComputerTask(request: ComputerExecuteRequest): Promise<ComputerTask> {
const response = await fetch(`${API_BASE}/api/v1/computer/execute`, {
if (!response.ok) {
throw new Error(`Dashboard fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchAdminUsers(
page = 1,
perPage = 20,
search?: string
): Promise<{ users: AdminUser[]; total: number; page: number; perPage: number }> {
const params = new URLSearchParams({ page: String(page), perPage: String(perPage) });
if (search) params.append('search', search);
const response = await fetch(`${API_BASE}/api/v1/admin/users?${params}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Users fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchAdminUser(id: string): Promise<AdminUser> {
const response = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`User fetch failed: ${response.status}`);
}
return response.json();
}
export async function createAdminUser(data: AdminUserCreateRequest): Promise<AdminUser> {
const response = await fetch(`${API_BASE}/api/v1/admin/users`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Computer execute failed: ${response.status}`);
throw new Error(`User create failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerTasks(
userId?: string,
limit = 20,
offset = 0
): Promise<{ tasks: ComputerTask[]; count: number }> {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (userId) {
params.set('userId', userId);
}
const response = await fetch(`${API_BASE}/api/v1/computer/tasks?${params}`, {
export async function updateAdminUser(id: string, data: AdminUserUpdateRequest): Promise<AdminUser> {
const response = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Computer tasks fetch failed: ${response.status}`);
throw new Error(`User update failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerTask(id: string): Promise<ComputerTask | null> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Computer task fetch failed: ${response.status}`);
}
return response.json();
}
export async function* streamComputerTask(id: string): AsyncGenerator<ComputerTaskEvent> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/stream`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer stream failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6)) as ComputerTaskEvent;
yield event;
} catch {
console.warn('Failed to parse computer event:', line);
}
}
}
}
}
export async function resumeComputerTask(id: string, userInput: string): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/resume`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ userInput }),
});
if (!response.ok) {
throw new Error(`Computer resume failed: ${response.status}`);
}
return response.json();
}
export async function cancelComputerTask(id: string): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
export async function deleteAdminUser(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer cancel failed: ${response.status}`);
throw new Error(`User delete failed: ${response.status}`);
}
}
export async function fetchAdminPosts(
page = 1,
perPage = 20,
status?: string,
category?: string
): Promise<{ posts: AdminPost[]; total: number; page: number; perPage: number }> {
const params = new URLSearchParams({ page: String(page), perPage: String(perPage) });
if (status) params.append('status', status);
if (category) params.append('category', category);
const response = await fetch(`${API_BASE}/api/v1/admin/posts?${params}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Posts fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerArtifacts(taskId: string): Promise<{ artifacts: Artifact[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${taskId}/artifacts`, {
export async function fetchAdminPost(id: string): Promise<AdminPost> {
const response = await fetch(`${API_BASE}/api/v1/admin/posts/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer artifacts fetch failed: ${response.status}`);
throw new Error(`Post fetch failed: ${response.status}`);
}
return response.json();
}
export async function downloadArtifact(id: string): Promise<Blob> {
const response = await fetch(`${API_BASE}/api/v1/computer/artifacts/${id}/download`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Artifact download failed: ${response.status}`);
}
return response.blob();
}
export async function fetchComputerModels(): Promise<{ models: ComputerModel[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/models`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer models fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerConnectors(): Promise<{ connectors: ComputerConnector[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/connectors`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer connectors fetch failed: ${response.status}`);
}
return response.json();
}
export async function executeConnectorAction(
connectorId: string,
action: string,
params: Record<string, unknown>
): Promise<unknown> {
const response = await fetch(`${API_BASE}/api/v1/computer/connectors/${connectorId}/execute`, {
export async function createAdminPost(data: AdminPostCreateRequest): Promise<AdminPost> {
const response = await fetch(`${API_BASE}/api/v1/admin/posts`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ action, params }),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Connector execute failed: ${response.status}`);
throw new Error(`Post create failed: ${response.status}`);
}
return response.json();
}
export async function updateAdminPost(id: string, data: AdminPostUpdateRequest): Promise<AdminPost> {
const response = await fetch(`${API_BASE}/api/v1/admin/posts/${id}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Post update failed: ${response.status}`);
}
return response.json();
}
export async function deleteAdminPost(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/admin/posts/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Post delete failed: ${response.status}`);
}
}
export async function publishAdminPost(id: string): Promise<AdminPost> {
const response = await fetch(`${API_BASE}/api/v1/admin/posts/${id}/publish`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Post publish failed: ${response.status}`);
}
return response.json();
}
export async function fetchPlatformSettings(): Promise<PlatformSettings> {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Settings fetch failed: ${response.status}`);
}
return response.json();
}
export async function updatePlatformSettings(data: Partial<PlatformSettings>): Promise<PlatformSettings> {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Settings update failed: ${response.status}`);
}
return response.json();
}
export async function fetchFeatureFlags(): Promise<FeatureFlags> {
const response = await fetch(`${API_BASE}/api/v1/admin/settings/features`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Features fetch failed: ${response.status}`);
}
return response.json();
}
export async function updateFeatureFlags(data: FeatureFlags): Promise<FeatureFlags> {
const response = await fetch(`${API_BASE}/api/v1/admin/settings/features`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Features update failed: ${response.status}`);
}
return response.json();
}
export async function fetchDiscoverCategories(): Promise<{ categories: DiscoverCategory[] }> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/categories`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Categories fetch failed: ${response.status}`);
}
return response.json();
}
export async function createDiscoverCategory(data: Omit<DiscoverCategory, 'id' | 'createdAt' | 'updatedAt' | 'sortOrder' | 'isActive'>): Promise<DiscoverCategory> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/categories`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Category create failed: ${response.status}`);
}
return response.json();
}
export async function updateDiscoverCategory(id: string, data: Partial<DiscoverCategory>): Promise<DiscoverCategory> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/categories/${id}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Category update failed: ${response.status}`);
}
return response.json();
}
export async function deleteDiscoverCategory(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/categories/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Category delete failed: ${response.status}`);
}
}
export async function reorderDiscoverCategories(order: string[]): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/categories/reorder`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ order }),
});
if (!response.ok) {
throw new Error(`Categories reorder failed: ${response.status}`);
}
}
export async function fetchDiscoverSources(): Promise<{ sources: DiscoverSource[] }> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/sources`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Sources fetch failed: ${response.status}`);
}
return response.json();
}
export async function createDiscoverSource(data: Omit<DiscoverSource, 'id' | 'createdAt' | 'updatedAt' | 'isActive'>): Promise<DiscoverSource> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/sources`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Source create failed: ${response.status}`);
}
return response.json();
}
export async function deleteDiscoverSource(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/admin/discover/sources/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Source delete failed: ${response.status}`);
}
}
export async function fetchAuditLogs(
page = 1,
perPage = 50,
action?: string,
resource?: string
): Promise<{ logs: AuditLog[]; total: number; page: number; perPage: number }> {
const params = new URLSearchParams({ page: String(page), perPage: String(perPage) });
if (action) params.append('action', action);
if (resource) params.append('resource', resource);
const response = await fetch(`${API_BASE}/api/v1/admin/audit?${params}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Audit logs fetch failed: ${response.status}`);
}
return response.json();

View File

@@ -0,0 +1,276 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: 'user' | 'admin';
tier: 'free' | 'pro' | 'business';
emailVerified: boolean;
provider: string;
createdAt: string;
updatedAt: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: string;
user: User;
}
export interface RegisterRequest {
email: string;
password: string;
name: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface ResetPasswordRequest {
email: string;
}
export interface ResetPasswordConfirm {
token: string;
newPassword: string;
}
export interface UpdateProfileRequest {
name: string;
avatar?: string;
}
const TOKEN_KEY = 'token';
const REFRESH_TOKEN_KEY = 'refreshToken';
const USER_KEY = 'user';
export function getStoredToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function getStoredRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function getStoredUser(): User | null {
if (typeof window === 'undefined') return null;
const data = localStorage.getItem(USER_KEY);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
export function storeAuth(tokens: AuthTokens): void {
localStorage.setItem(TOKEN_KEY, tokens.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(tokens.user));
}
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${response.status}`);
}
return response.json();
}
export async function register(data: RegisterRequest): Promise<AuthTokens> {
const response = await fetch(`${API_BASE}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const tokens = await handleResponse<AuthTokens>(response);
storeAuth(tokens);
return tokens;
}
export async function login(data: LoginRequest): Promise<AuthTokens> {
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const tokens = await handleResponse<AuthTokens>(response);
storeAuth(tokens);
return tokens;
}
export async function refreshTokens(): Promise<AuthTokens | null> {
const refreshToken = getStoredRefreshToken();
if (!refreshToken) return null;
try {
const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
clearAuth();
return null;
}
const tokens = await response.json();
storeAuth(tokens);
return tokens;
} catch {
clearAuth();
return null;
}
}
export async function logout(): Promise<void> {
const token = getStoredToken();
const refreshToken = getStoredRefreshToken();
if (token) {
try {
await fetch(`${API_BASE}/api/v1/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ refreshToken }),
});
} catch {
// Ignore errors during logout
}
}
clearAuth();
}
export async function logoutAll(): Promise<void> {
const token = getStoredToken();
if (token) {
try {
await fetch(`${API_BASE}/api/v1/auth/logout-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
} catch {
// Ignore errors
}
}
clearAuth();
}
export async function getMe(): Promise<User | null> {
const token = getStoredToken();
if (!token) return null;
try {
const response = await fetch(`${API_BASE}/api/v1/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 401) {
const refreshed = await refreshTokens();
if (refreshed) {
return getMe();
}
return null;
}
return null;
}
return response.json();
} catch {
return null;
}
}
export async function updateProfile(data: UpdateProfileRequest): Promise<User> {
const token = getStoredToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${API_BASE}/api/v1/auth/me`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});
const user = await handleResponse<User>(response);
const storedUser = getStoredUser();
if (storedUser) {
localStorage.setItem(USER_KEY, JSON.stringify({ ...storedUser, ...user }));
}
return user;
}
export async function changePassword(data: ChangePasswordRequest): Promise<void> {
const token = getStoredToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${API_BASE}/api/v1/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});
await handleResponse<{ message: string }>(response);
}
export async function forgotPassword(email: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
await handleResponse<{ message: string }>(response);
}
export async function resetPassword(data: ResetPasswordConfirm): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
await handleResponse<{ message: string }>(response);
}
export function isAuthenticated(): boolean {
return !!getStoredToken();
}

View File

@@ -0,0 +1,56 @@
/**
* Конфигурация видимости пунктов меню
*
* Управляется через переменные окружения:
* - NEXT_PUBLIC_DISABLED_ROUTES — список отключённых маршрутов через запятую
*
* Пример: NEXT_PUBLIC_DISABLED_ROUTES=/travel,/medicine,/finance,/learning
*/
type RouteId =
| '/'
| '/discover'
| '/spaces'
| '/history'
| '/travel'
| '/medicine'
| '/finance'
| '/learning'
| '/settings';
const parseDisabledRoutes = (): Set<string> => {
const envValue = process.env.NEXT_PUBLIC_DISABLED_ROUTES || '';
if (!envValue.trim()) {
return new Set();
}
const routes = envValue
.split(',')
.map(route => route.trim())
.filter(route => route.startsWith('/'));
return new Set(routes);
};
const disabledRoutes = parseDisabledRoutes();
/**
* Проверяет, включён ли маршрут для отображения
*/
export function isRouteEnabled(route: string): boolean {
return !disabledRoutes.has(route);
}
/**
* Фильтрует массив пунктов меню по включённым маршрутам
*/
export function filterMenuItems<T extends { href: string }>(items: T[]): T[] {
return items.filter(item => isRouteEnabled(item.href));
}
/**
* Получает список отключённых маршрутов
*/
export function getDisabledRoutes(): string[] {
return Array.from(disabledRoutes);
}

View File

@@ -0,0 +1,141 @@
'use client';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
ReactNode,
} from 'react';
import {
User,
AuthTokens,
RegisterRequest,
LoginRequest,
getStoredUser,
getStoredToken,
login as apiLogin,
register as apiRegister,
logout as apiLogout,
getMe,
refreshTokens,
clearAuth,
} from '@/lib/auth';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (data: LoginRequest) => Promise<AuthTokens>;
register: (data: RegisterRequest) => Promise<AuthTokens>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
showAuthModal: (mode: 'login' | 'register') => void;
hideAuthModal: () => void;
authModalMode: 'login' | 'register' | null;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authModalMode, setAuthModalMode] = useState<'login' | 'register' | null>(null);
const initAuth = useCallback(async () => {
const token = getStoredToken();
if (!token) {
setIsLoading(false);
return;
}
const storedUser = getStoredUser();
if (storedUser) {
setUser(storedUser);
}
try {
const freshUser = await getMe();
if (freshUser) {
setUser(freshUser);
} else {
setUser(null);
clearAuth();
}
} catch {
const refreshed = await refreshTokens();
if (refreshed) {
setUser(refreshed.user);
} else {
setUser(null);
clearAuth();
}
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
initAuth();
}, [initAuth]);
const login = useCallback(async (data: LoginRequest): Promise<AuthTokens> => {
const tokens = await apiLogin(data);
setUser(tokens.user);
setAuthModalMode(null);
return tokens;
}, []);
const register = useCallback(async (data: RegisterRequest): Promise<AuthTokens> => {
const tokens = await apiRegister(data);
setUser(tokens.user);
setAuthModalMode(null);
return tokens;
}, []);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
const refreshUser = useCallback(async () => {
const freshUser = await getMe();
setUser(freshUser);
}, []);
const showAuthModal = useCallback((mode: 'login' | 'register') => {
setAuthModalMode(mode);
}, []);
const hideAuthModal = useCallback(() => {
setAuthModalMode(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
refreshUser,
showAuthModal,
hideAuthModal,
authModalMode,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,61 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export type SupportedLanguage = 'ru' | 'en';
interface LanguageContextType {
language: SupportedLanguage;
setLanguage: (lang: SupportedLanguage) => void;
getLanguageName: () => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const STORAGE_KEY = 'gooseek_language';
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<SupportedLanguage>('ru');
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'ru' || saved === 'en') {
setLanguageState(saved);
}
setIsHydrated(true);
}, []);
const setLanguage = (lang: SupportedLanguage) => {
setLanguageState(lang);
localStorage.setItem(STORAGE_KEY, lang);
};
const getLanguageName = (): string => {
return language === 'ru' ? 'Russian' : 'English';
};
if (!isHydrated) {
return null;
}
return (
<LanguageContext.Provider value={{ language, setLanguage, getLanguageName }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage(): LanguageContextType {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}
export function getLanguageFromStorage(): SupportedLanguage {
if (typeof window === 'undefined') return 'ru';
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'en' ? 'en' : 'ru';
}

View File

@@ -1,8 +1,19 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { Message, Citation, Widget, StreamEvent } from '../types';
import type { Message, Citation, Widget, StreamEvent, ChatAttachmentInfo } from '../types';
import { streamChat, generateId } from '../api';
import { getLanguageFromStorage } from '../contexts/LanguageContext';
import type { SendOptions, ChatAttachment } from '@/components/ChatInput';
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
interface UseChatOptions {
onError?: (error: Error) => void;
@@ -15,7 +26,11 @@ export function useChat(options: UseChatOptions = {}) {
const [widgets, setWidgets] = useState<Widget[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string, mode: 'speed' | 'balanced' | 'quality' = 'balanced') => {
const sendMessage = useCallback(async (content: string, sendOptions?: SendOptions | 'speed' | 'balanced' | 'quality') => {
const mode = typeof sendOptions === 'string' ? sendOptions : sendOptions?.mode ?? 'balanced';
const webSearch = typeof sendOptions === 'object' ? sendOptions.webSearch : false;
const attachments = typeof sendOptions === 'object' ? sendOptions.attachments : [];
const locale = getLanguageFromStorage();
if (!content.trim() || isLoading) return;
const userMessage: Message = {
@@ -49,6 +64,21 @@ export function useChat(options: UseChatOptions = {}) {
return acc;
}, [] as [string, string][]);
const attachmentInfos = await Promise.all(
attachments.map(async (att) => {
let dataUrl: string | undefined;
if (att.type === 'image' || att.file.type.startsWith('image/')) {
dataUrl = await fileToDataUrl(att.file);
}
return {
name: att.file.name,
type: att.file.type,
size: att.file.size,
dataUrl,
};
})
);
try {
const stream = streamChat({
message: {
@@ -58,7 +88,9 @@ export function useChat(options: UseChatOptions = {}) {
},
optimizationMode: mode,
history,
locale: 'ru',
locale,
webSearch,
attachments: attachmentInfos.length > 0 ? attachmentInfos : undefined,
});
let fullContent = '';

View File

@@ -131,6 +131,13 @@ export interface StreamEvent {
}>;
}
export interface ChatAttachmentInfo {
name: string;
type: string;
size: number;
dataUrl?: string;
}
export interface ChatRequest {
message: {
messageId: string;
@@ -145,6 +152,8 @@ export interface ChatRequest {
key: string;
};
locale?: string;
webSearch?: boolean;
attachments?: ChatAttachmentInfo[];
}
export interface ApiConfig {
@@ -253,201 +262,163 @@ export interface PracticeExercise {
language: string;
}
// =====================
// Computer Types (Perplexity Computer style)
// =====================
export type UserRole = 'user' | 'admin';
export type UserTier = 'free' | 'pro' | 'business';
export type ComputerTaskStatus =
| 'pending'
| 'planning'
| 'executing'
| 'waiting_user'
| 'completed'
| 'failed'
| 'cancelled'
| 'scheduled'
| 'paused'
| 'checkpoint'
| 'long_running';
export type ComputerTaskType =
| 'research'
| 'code'
| 'analysis'
| 'design'
| 'deploy'
| 'monitor'
| 'report'
| 'communicate'
| 'schedule'
| 'transform'
| 'validate';
export type DurationMode = 'short' | 'medium' | 'long' | 'extended' | 'unlimited';
export type TaskPriority = 'low' | 'normal' | 'high' | 'critical';
export interface ComputerTask {
export interface AdminUser {
id: string;
userId: string;
query: string;
status: ComputerTaskStatus;
plan?: TaskPlan;
subTasks?: SubTask[];
artifacts?: Artifact[];
memory?: Record<string, unknown>;
progress: number;
message?: string;
error?: string;
schedule?: ComputerSchedule;
nextRunAt?: string;
runCount: number;
totalCost: number;
email: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
tier: UserTier;
isActive: boolean;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
durationMode: DurationMode;
checkpoint?: Checkpoint;
checkpoints?: Checkpoint[];
maxDuration: number;
estimatedEnd?: string;
iterations: number;
maxIterations: number;
pausedAt?: string;
resumedAt?: string;
totalRuntime: number;
heartbeatAt?: string;
priority: TaskPriority;
resourceLimits?: ResourceLimits;
}
export interface TaskPlan {
query: string;
summary: string;
subTasks: SubTask[];
executionOrder: string[][];
estimatedCost: number;
estimatedTimeSeconds: number;
export interface AdminUserCreateRequest {
email: string;
password: string;
displayName: string;
role?: UserRole;
tier?: UserTier;
}
export interface SubTask {
export interface AdminUserUpdateRequest {
email?: string;
displayName?: string;
role?: UserRole;
tier?: UserTier;
isActive?: boolean;
}
export interface AdminPost {
id: string;
type: ComputerTaskType;
description: string;
dependencies?: string[];
modelId?: string;
requiredCaps?: string[];
input?: Record<string, unknown>;
output?: Record<string, unknown>;
status: ComputerTaskStatus;
progress: number;
error?: string;
cost: number;
startedAt?: string;
completedAt?: string;
retries: number;
maxRetries: number;
}
export interface Artifact {
id: string;
taskId: string;
type: 'file' | 'code' | 'report' | 'deployment' | 'image' | 'data';
name: string;
url?: string;
size: number;
mimeType?: string;
metadata?: Record<string, unknown>;
title: string;
slug: string;
content: string;
excerpt?: string;
coverImage?: string;
authorId: string;
authorName?: string;
category: string;
tags: string[];
status: 'draft' | 'published' | 'archived';
viewCount: number;
publishedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface ComputerSchedule {
type: 'once' | 'interval' | 'cron' | 'hourly' | 'daily' | 'weekly' | 'monthly';
cronExpr?: string;
intervalSeconds?: number;
nextRun: string;
maxRuns: number;
runCount: number;
expiresAt?: string;
enabled: boolean;
timezone?: string;
export interface AdminPostCreateRequest {
title: string;
content: string;
excerpt?: string;
coverImage?: string;
category: string;
tags?: string[];
status?: string;
}
export interface Checkpoint {
export interface AdminPostUpdateRequest {
title?: string;
content?: string;
excerpt?: string;
coverImage?: string;
category?: string;
tags?: string[];
status?: string;
}
export interface PlatformSettings {
id: string;
taskId: string;
subTaskIndex: number;
waveIndex: number;
state: Record<string, unknown>;
progress: number;
artifacts: string[];
memory: Record<string, unknown>;
createdAt: string;
runtimeSoFar: number;
costSoFar: number;
reason: string;
}
export interface ResourceLimits {
maxCpu: number;
maxMemoryMb: number;
maxDiskMb: number;
maxNetworkMbps: number;
maxCostPerHour: number;
maxTotalCost: number;
maxConcurrent: number;
idleTimeoutMins: number;
}
export interface ComputerTaskEvent {
type: string;
taskId: string;
subTaskId?: string;
status?: ComputerTaskStatus;
progress?: number;
message?: string;
data?: Record<string, unknown>;
timestamp: string;
}
export interface ComputerExecuteRequest {
query: string;
userId?: string;
options?: {
async?: boolean;
maxCost?: number;
timeoutSeconds?: number;
enableSandbox?: boolean;
schedule?: Partial<ComputerSchedule>;
context?: Record<string, unknown>;
durationMode?: DurationMode;
priority?: TaskPriority;
resourceLimits?: Partial<ResourceLimits>;
resumeFromId?: string;
enableBrowser?: boolean;
notifyOnEvents?: string[];
webhookUrl?: string;
tags?: string[];
};
}
export interface ComputerModel {
id: string;
provider: string;
model: string;
capabilities: string[];
costPer1K: number;
maxContext: number;
maxTokens: number;
priority: number;
siteName: string;
siteUrl: string;
logoUrl?: string;
faviconUrl?: string;
description?: string;
supportEmail?: string;
features: FeatureFlags;
llmSettings: LLMSettings;
searchSettings: SearchSettings;
metadata?: Record<string, unknown>;
updatedAt: string;
}
export interface ComputerConnector {
export interface FeatureFlags {
enableRegistration: boolean;
enableDiscover: boolean;
enableFinance: boolean;
enableLearning: boolean;
enableTravel: boolean;
enableMedicine: boolean;
enableFileUploads: boolean;
maintenanceMode: boolean;
}
export interface LLMSettings {
defaultProvider: string;
defaultModel: string;
maxTokens: number;
temperature: number;
}
export interface SearchSettings {
defaultEngine: string;
safeSearch: boolean;
maxResults: number;
enabledCategories: string[];
}
export interface DiscoverCategory {
id: string;
name: string;
description: string;
actions: string[];
enabled: boolean;
configRequired: string[];
nameRu: string;
icon: string;
color: string;
keywords: string[];
regions: string[];
isActive: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface DiscoverSource {
id: string;
name: string;
url: string;
logoUrl?: string;
categories: string[];
trustScore: number;
isActive: boolean;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface DashboardStats {
totalUsers: number;
activeUsers: number;
totalPosts: number;
publishedPosts: number;
totalSearches: number;
todaySearches: number;
storageUsedMb: number;
storageLimitMb: number;
}
export interface AuditLog {
id: string;
userId: string;
userEmail: string;
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
createdAt: string;
}