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:
33
backend/webui/src/app/(auth)/forgot-password/page.tsx
Normal file
33
backend/webui/src/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
backend/webui/src/app/(auth)/layout.tsx
Normal file
5
backend/webui/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
62
backend/webui/src/app/(auth)/login/page.tsx
Normal file
62
backend/webui/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
backend/webui/src/app/(auth)/register/page.tsx
Normal file
58
backend/webui/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
backend/webui/src/app/(auth)/reset-password/page.tsx
Normal file
208
backend/webui/src/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
backend/webui/src/app/(main)/admin/audit/page.tsx
Normal file
225
backend/webui/src/app/(main)/admin/audit/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
User,
|
||||
FileText,
|
||||
Settings,
|
||||
Compass,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { fetchAuditLogs } from '@/lib/api';
|
||||
import type { AuditLog } from '@/lib/types';
|
||||
|
||||
const actionIcons: Record<string, React.ElementType> = {
|
||||
create: FileText,
|
||||
update: Settings,
|
||||
delete: Shield,
|
||||
publish: Compass,
|
||||
reorder: Settings,
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
create: 'Создание',
|
||||
update: 'Изменение',
|
||||
delete: 'Удаление',
|
||||
publish: 'Публикация',
|
||||
reorder: 'Сортировка',
|
||||
};
|
||||
|
||||
const resourceLabels: Record<string, string> = {
|
||||
user: 'Пользователь',
|
||||
post: 'Пост',
|
||||
settings: 'Настройки',
|
||||
discover_category: 'Категория Discover',
|
||||
discover_categories: 'Категории Discover',
|
||||
discover_source: 'Источник Discover',
|
||||
};
|
||||
|
||||
export default function AdminAuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [resourceFilter, setResourceFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAuditLogs(
|
||||
page,
|
||||
50,
|
||||
actionFilter || undefined,
|
||||
resourceFilter || undefined
|
||||
);
|
||||
setLogs(data.logs);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audit logs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, actionFilter, resourceFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [loadLogs]);
|
||||
|
||||
const totalPages = Math.ceil(total / 50);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return 'bg-green-500/10 text-green-400';
|
||||
case 'update':
|
||||
return 'bg-blue-500/10 text-blue-400';
|
||||
case 'delete':
|
||||
return 'bg-red-500/10 text-red-400';
|
||||
case 'publish':
|
||||
return 'bg-purple-500/10 text-purple-400';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Аудит</h1>
|
||||
<p className="text-muted">История действий администраторов</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl border border-border/30">
|
||||
<div className="p-4 border-b border-border/30 flex gap-4">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => {
|
||||
setActionFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Все действия</option>
|
||||
<option value="create">Создание</option>
|
||||
<option value="update">Изменение</option>
|
||||
<option value="delete">Удаление</option>
|
||||
<option value="publish">Публикация</option>
|
||||
</select>
|
||||
<select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => {
|
||||
setResourceFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Все ресурсы</option>
|
||||
<option value="user">Пользователи</option>
|
||||
<option value="post">Посты</option>
|
||||
<option value="settings">Настройки</option>
|
||||
<option value="discover_category">Категории Discover</option>
|
||||
<option value="discover_source">Источники Discover</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted">
|
||||
<Shield className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>Записей аудита нет</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y divide-border/20">
|
||||
{logs.map((log) => {
|
||||
const ActionIcon = actionIcons[log.action] || Shield;
|
||||
return (
|
||||
<div key={log.id} className="px-4 py-3 hover:bg-base/50">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg ${getActionColor(log.action)}`}>
|
||||
<ActionIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${getActionColor(log.action)}`}>
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
<span className="text-secondary">
|
||||
{resourceLabels[log.resource] || log.resource}
|
||||
</span>
|
||||
{log.resourceId && (
|
||||
<span className="text-xs text-muted font-mono">
|
||||
{log.resourceId.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<User className="w-3 h-3" />
|
||||
{log.userEmail}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(log.createdAt)}
|
||||
</span>
|
||||
{log.ipAddress && (
|
||||
<span className="text-xs text-muted">
|
||||
IP: {log.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
|
||||
<p className="text-sm text-muted">
|
||||
Показано {logs.length} из {total}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-muted">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
513
backend/webui/src/app/(main)/admin/discover/page.tsx
Normal file
513
backend/webui/src/app/(main)/admin/discover/page.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ExternalLink,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
fetchDiscoverCategories,
|
||||
createDiscoverCategory,
|
||||
updateDiscoverCategory,
|
||||
deleteDiscoverCategory,
|
||||
reorderDiscoverCategories,
|
||||
fetchDiscoverSources,
|
||||
createDiscoverSource,
|
||||
deleteDiscoverSource,
|
||||
} from '@/lib/api';
|
||||
import type { DiscoverCategory, DiscoverSource } from '@/lib/types';
|
||||
|
||||
interface CategoryModalProps {
|
||||
category?: DiscoverCategory;
|
||||
onClose: () => void;
|
||||
onSave: (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => void;
|
||||
}
|
||||
|
||||
function CategoryModal({ category, onClose, onSave }: CategoryModalProps) {
|
||||
const [name, setName] = useState(category?.name || '');
|
||||
const [nameRu, setNameRu] = useState(category?.nameRu || '');
|
||||
const [icon, setIcon] = useState(category?.icon || '📰');
|
||||
const [color, setColor] = useState(category?.color || '#6B7280');
|
||||
const [keywords, setKeywords] = useState(category?.keywords?.join(', ') || '');
|
||||
const [regions, setRegions] = useState(category?.regions?.join(', ') || 'world, russia');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name,
|
||||
nameRu,
|
||||
icon,
|
||||
color,
|
||||
keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
|
||||
regions: regions.split(',').map(r => r.trim()).filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{category ? 'Редактировать категорию' : 'Новая категория'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">ID (англ.)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="tech"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Название (рус.)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nameRu}
|
||||
onChange={(e) => setNameRu(e.target.value)}
|
||||
placeholder="Технологии"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Иконка</label>
|
||||
<input
|
||||
type="text"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="💻"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary text-center text-2xl"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Цвет</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-12 h-10 bg-base border border-border/50 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Ключевые слова (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder="technology, AI, software"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Регионы (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regions}
|
||||
onChange={(e) => setRegions(e.target.value)}
|
||||
placeholder="world, russia, eu"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceModalProps {
|
||||
onClose: () => void;
|
||||
onSave: (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => void;
|
||||
}
|
||||
|
||||
function SourceModal({ onClose, onSave }: SourceModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [categories, setCategories] = useState('');
|
||||
const [trustScore, setTrustScore] = useState(0.5);
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name,
|
||||
url,
|
||||
logoUrl: logoUrl || undefined,
|
||||
categories: categories.split(',').map(c => c.trim()).filter(Boolean),
|
||||
trustScore,
|
||||
description: description || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">Новый источник</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Habr"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://habr.com"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">URL логотипа</label>
|
||||
<input
|
||||
type="url"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Категории (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={categories}
|
||||
onChange={(e) => setCategories(e.target.value)}
|
||||
placeholder="tech, science"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">
|
||||
Уровень доверия: {trustScore.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={trustScore}
|
||||
onChange={(e) => setTrustScore(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Описание</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDiscoverPage() {
|
||||
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
||||
const [sources, setSources] = useState<DiscoverSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryModal, setCategoryModal] = useState<DiscoverCategory | null | undefined>(undefined);
|
||||
const [showSourceModal, setShowSourceModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'categories' | 'sources'>('categories');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [catData, srcData] = await Promise.all([
|
||||
fetchDiscoverCategories(),
|
||||
fetchDiscoverSources(),
|
||||
]);
|
||||
setCategories(catData.categories || []);
|
||||
setSources(srcData.sources || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load discover config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSaveCategory = async (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => {
|
||||
try {
|
||||
if (categoryModal) {
|
||||
await updateDiscoverCategory(categoryModal.id, data);
|
||||
} else {
|
||||
await createDiscoverCategory(data);
|
||||
}
|
||||
setCategoryModal(undefined);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to save category:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCategory = async (id: string) => {
|
||||
if (!confirm('Удалить категорию?')) return;
|
||||
try {
|
||||
await deleteDiscoverCategory(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete category:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCategory = async (cat: DiscoverCategory) => {
|
||||
try {
|
||||
await updateDiscoverCategory(cat.id, { isActive: !cat.isActive });
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle category:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSource = async (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => {
|
||||
try {
|
||||
await createDiscoverSource(data);
|
||||
setShowSourceModal(false);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to save source:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSource = async (id: string) => {
|
||||
if (!confirm('Удалить источник?')) return;
|
||||
try {
|
||||
await deleteDiscoverSource(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete source:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Discover</h1>
|
||||
<p className="text-muted">Управление категориями и источниками новостей</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-border/30">
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'categories'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
Категории ({categories.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'sources'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
Источники ({sources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'categories' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30">
|
||||
<div className="p-4 border-b border-border/30 flex items-center justify-between">
|
||||
<p className="text-sm text-muted">Перетащите для изменения порядка</p>
|
||||
<button
|
||||
onClick={() => setCategoryModal(null)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/20">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
|
||||
<GripVertical className="w-4 h-4 text-muted cursor-grab" />
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: cat.color + '20' }}
|
||||
>
|
||||
{cat.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-primary font-medium">{cat.nameRu}</p>
|
||||
<p className="text-xs text-muted">{cat.name} • {cat.keywords.join(', ')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleCategory(cat)}
|
||||
className={`p-1 rounded ${cat.isActive ? 'text-green-400' : 'text-muted'}`}
|
||||
>
|
||||
{cat.isActive ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategoryModal(cat)}
|
||||
className="p-1 text-muted hover:text-primary rounded"
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(cat.id)}
|
||||
className="p-1 text-muted hover:text-red-400 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'sources' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30">
|
||||
<div className="p-4 border-b border-border/30 flex items-center justify-between">
|
||||
<p className="text-sm text-muted">Доверенные источники новостей</p>
|
||||
<button
|
||||
onClick={() => setShowSourceModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted">
|
||||
<ExternalLink className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>Источников пока нет</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/20">
|
||||
{sources.map((source) => (
|
||||
<div key={source.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
|
||||
<div className="w-10 h-10 rounded-lg bg-base flex items-center justify-center">
|
||||
{source.logoUrl ? (
|
||||
<img src={source.logoUrl} alt="" className="w-6 h-6" />
|
||||
) : (
|
||||
<ExternalLink className="w-5 h-5 text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-primary font-medium">{source.name}</p>
|
||||
<p className="text-xs text-muted">{source.url}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted">Доверие:</span>
|
||||
<span className={`ml-1 font-medium ${
|
||||
source.trustScore >= 0.7 ? 'text-green-400' :
|
||||
source.trustScore >= 0.4 ? 'text-yellow-400' : 'text-red-400'
|
||||
}`}>
|
||||
{(source.trustScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteSource(source.id)}
|
||||
className="p-1 text-muted hover:text-red-400 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoryModal !== undefined && (
|
||||
<CategoryModal
|
||||
category={categoryModal || undefined}
|
||||
onClose={() => setCategoryModal(undefined)}
|
||||
onSave={handleSaveCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSourceModal && (
|
||||
<SourceModal
|
||||
onClose={() => setShowSourceModal(false)}
|
||||
onSave={handleSaveSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
backend/webui/src/app/(main)/admin/layout.tsx
Normal file
125
backend/webui/src/app/(main)/admin/layout.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
Compass,
|
||||
Settings,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: '/admin', icon: LayoutDashboard, label: 'Дашборд' },
|
||||
{ href: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ href: '/admin/posts', icon: FileText, label: 'Посты' },
|
||||
{ href: '/admin/discover', icon: Compass, label: 'Discover' },
|
||||
{ href: '/admin/settings', icon: Settings, label: 'Настройки' },
|
||||
{ href: '/admin/audit', icon: Shield, label: 'Аудит' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.role !== 'admin') {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAdmin(true);
|
||||
} catch {
|
||||
router.push('/');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (isAdmin === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<aside className="w-64 bg-surface border-r border-border/50 flex flex-col">
|
||||
<div className="p-4 border-b border-border/30">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm">Вернуться на сайт</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h1 className="text-lg font-bold text-primary flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Админ-панель
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-2 space-y-1">
|
||||
{adminNavItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all
|
||||
${isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-secondary hover:text-primary hover:bg-surface/80'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px]" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border/30">
|
||||
<div className="text-xs text-muted">
|
||||
GooSeek Admin v1.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-base">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
backend/webui/src/app/(main)/admin/page.tsx
Normal file
180
backend/webui/src/app/(main)/admin/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
Search,
|
||||
HardDrive,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { fetchAdminDashboard } from '@/lib/api';
|
||||
import type { DashboardStats } from '@/lib/types';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: React.ElementType;
|
||||
subValue?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon, subValue, color }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-surface rounded-xl p-6 border border-border/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted mb-1">{title}</p>
|
||||
<p className="text-3xl font-bold text-primary">{value}</p>
|
||||
{subValue && (
|
||||
<p className="text-xs text-muted mt-1">{subValue}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${color}`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await fetchAdminDashboard();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const storagePercent = stats
|
||||
? Math.round((stats.storageUsedMb / stats.storageLimitMb) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Дашборд</h1>
|
||||
<p className="text-muted">Обзор платформы GooSeek</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Всего пользователей"
|
||||
value={stats?.totalUsers || 0}
|
||||
icon={Users}
|
||||
subValue={`${stats?.activeUsers || 0} активных`}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Посты"
|
||||
value={stats?.totalPosts || 0}
|
||||
icon={FileText}
|
||||
subValue={`${stats?.publishedPosts || 0} опубликовано`}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Поисков сегодня"
|
||||
value={stats?.todaySearches || 0}
|
||||
icon={Search}
|
||||
subValue={`${stats?.totalSearches || 0} всего`}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Хранилище"
|
||||
value={`${stats?.storageUsedMb || 0} МБ`}
|
||||
icon={HardDrive}
|
||||
subValue={`${storagePercent}% использовано`}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-surface rounded-xl p-6 border border-border/30">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-primary">Активность</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary">Регистрации за неделю</span>
|
||||
<span className="text-primary font-medium">—</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary">Новые посты за неделю</span>
|
||||
<span className="text-primary font-medium">—</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary">Поисков за неделю</span>
|
||||
<span className="text-primary font-medium">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-border/30">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-primary">Статус сервисов</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{['API Gateway', 'Chat Service', 'Search Service', 'LLM Service', 'Discover Service'].map((service) => (
|
||||
<div key={service} className="flex items-center justify-between">
|
||||
<span className="text-secondary">{service}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-muted">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-border/30">
|
||||
<h2 className="text-lg font-semibold text-primary mb-4">Использование хранилища</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-secondary">
|
||||
{stats?.storageUsedMb || 0} МБ из {stats?.storageLimitMb || 0} МБ
|
||||
</span>
|
||||
<span className="text-muted">{storagePercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-border/30 rounded-full h-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(storagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
backend/webui/src/app/(main)/admin/posts/page.tsx
Normal file
415
backend/webui/src/app/(main)/admin/posts/page.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Send,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
fetchAdminPosts,
|
||||
createAdminPost,
|
||||
updateAdminPost,
|
||||
deleteAdminPost,
|
||||
publishAdminPost,
|
||||
} from '@/lib/api';
|
||||
import type { AdminPost } from '@/lib/types';
|
||||
|
||||
interface PostModalProps {
|
||||
post?: AdminPost;
|
||||
onClose: () => void;
|
||||
onSave: (data: { title: string; content: string; excerpt?: string; category: string; tags?: string[]; status?: string }) => void;
|
||||
}
|
||||
|
||||
function PostModal({ post, onClose, onSave }: PostModalProps) {
|
||||
const [title, setTitle] = useState(post?.title || '');
|
||||
const [content, setContent] = useState(post?.content || '');
|
||||
const [excerpt, setExcerpt] = useState(post?.excerpt || '');
|
||||
const [category, setCategory] = useState(post?.category || 'general');
|
||||
const [tags, setTags] = useState(post?.tags?.join(', ') || '');
|
||||
const [status, setStatus] = useState(post?.status || 'draft');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
title,
|
||||
content,
|
||||
excerpt: excerpt || undefined,
|
||||
category,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
status,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{post ? 'Редактировать пост' : 'Новый пост'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Краткое описание</label>
|
||||
<textarea
|
||||
value={excerpt}
|
||||
onChange={(e) => setExcerpt(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Содержимое</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Категория</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="general">Общее</option>
|
||||
<option value="news">Новости</option>
|
||||
<option value="tutorial">Туториалы</option>
|
||||
<option value="announcement">Объявления</option>
|
||||
<option value="blog">Блог</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Статус</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="draft">Черновик</option>
|
||||
<option value="published">Опубликован</option>
|
||||
<option value="archived">Архив</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Теги (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="ai, новости, обновление"
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalPost, setModalPost] = useState<AdminPost | null | undefined>(undefined);
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAdminPosts(page, 20, statusFilter || undefined);
|
||||
setPosts(data.posts);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
const handleSave = async (data: { title: string; content: string; excerpt?: string; category: string; tags?: string[]; status?: string }) => {
|
||||
try {
|
||||
if (modalPost) {
|
||||
await updateAdminPost(modalPost.id, data);
|
||||
} else {
|
||||
await createAdminPost(data);
|
||||
}
|
||||
setModalPost(undefined);
|
||||
loadPosts();
|
||||
} catch (err) {
|
||||
console.error('Failed to save post:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Удалить пост?')) return;
|
||||
try {
|
||||
await deleteAdminPost(id);
|
||||
loadPosts();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete post:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (id: string) => {
|
||||
try {
|
||||
await publishAdminPost(id);
|
||||
loadPosts();
|
||||
} catch (err) {
|
||||
console.error('Failed to publish post:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'bg-green-500/10 text-green-400';
|
||||
case 'draft':
|
||||
return 'bg-yellow-500/10 text-yellow-400';
|
||||
case 'archived':
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'Опубликован';
|
||||
case 'draft':
|
||||
return 'Черновик';
|
||||
case 'archived':
|
||||
return 'Архив';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Посты</h1>
|
||||
<p className="text-muted">Управление контентом платформы</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalPost(null)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl border border-border/30">
|
||||
<div className="p-4 border-b border-border/30 flex gap-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="draft">Черновики</option>
|
||||
<option value="published">Опубликованные</option>
|
||||
<option value="archived">Архив</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted">
|
||||
<FileText className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>Постов пока нет</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Заголовок</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Категория</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Статус</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Автор</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Просмотры</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Дата</th>
|
||||
<th className="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id} className="border-b border-border/20 hover:bg-base/50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-primary font-medium line-clamp-1">{post.title}</p>
|
||||
{post.excerpt && (
|
||||
<p className="text-xs text-muted line-clamp-1 mt-0.5">{post.excerpt}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary capitalize">
|
||||
{post.category}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${getStatusBadge(post.status)}`}>
|
||||
{getStatusLabel(post.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary">
|
||||
{post.authorName || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{post.viewCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{new Date(post.createdAt).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setActiveMenu(activeMenu === post.id ? null : post.id)}
|
||||
className="p-1 hover:bg-base rounded"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted" />
|
||||
</button>
|
||||
{activeMenu === post.id && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-surface border border-border/50 rounded-lg shadow-lg z-10 min-w-[150px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalPost(post);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Редактировать
|
||||
</button>
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handlePublish(post.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-green-400 hover:bg-base"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Опубликовать
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Просмотр
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDelete(post.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
|
||||
<p className="text-sm text-muted">
|
||||
Показано {posts.length} из {total}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-muted">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalPost !== undefined && (
|
||||
<PostModal
|
||||
post={modalPost || undefined}
|
||||
onClose={() => setModalPost(undefined)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
backend/webui/src/app/(main)/admin/settings/page.tsx
Normal file
424
backend/webui/src/app/(main)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Save,
|
||||
Globe,
|
||||
Cpu,
|
||||
Search,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
fetchPlatformSettings,
|
||||
updatePlatformSettings,
|
||||
fetchFeatureFlags,
|
||||
updateFeatureFlags,
|
||||
} from '@/lib/api';
|
||||
import type { PlatformSettings, FeatureFlags } from '@/lib/types';
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [settings, setSettings] = useState<PlatformSettings | null>(null);
|
||||
const [features, setFeatures] = useState<FeatureFlags | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'features' | 'llm' | 'search'>('general');
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [settingsData, featuresData] = await Promise.all([
|
||||
fetchPlatformSettings(),
|
||||
fetchFeatureFlags(),
|
||||
]);
|
||||
setSettings(settingsData);
|
||||
setFeatures(featuresData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const handleSaveGeneral = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updatePlatformSettings({
|
||||
siteName: settings.siteName,
|
||||
siteUrl: settings.siteUrl,
|
||||
logoUrl: settings.logoUrl,
|
||||
faviconUrl: settings.faviconUrl,
|
||||
description: settings.description,
|
||||
supportEmail: settings.supportEmail,
|
||||
});
|
||||
setSettings(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFeatures = async () => {
|
||||
if (!features) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateFeatureFlags(features);
|
||||
} catch (err) {
|
||||
console.error('Failed to save features:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLLM = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updatePlatformSettings({
|
||||
llmSettings: settings.llmSettings,
|
||||
});
|
||||
setSettings(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to save LLM settings:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSearch = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updatePlatformSettings({
|
||||
searchSettings: settings.searchSettings,
|
||||
});
|
||||
setSettings(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to save search settings:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFeature = (key: keyof FeatureFlags) => {
|
||||
if (!features) return;
|
||||
setFeatures({ ...features, [key]: !features[key] });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings || !features) {
|
||||
return (
|
||||
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
|
||||
Не удалось загрузить настройки
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Настройки</h1>
|
||||
<p className="text-muted">Конфигурация платформы GooSeek</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-border/30">
|
||||
{[
|
||||
{ id: 'general', label: 'Общие', icon: Globe },
|
||||
{ id: 'features', label: 'Функции', icon: ToggleRight },
|
||||
{ id: 'llm', label: 'LLM', icon: Cpu },
|
||||
{ id: 'search', label: 'Поиск', icon: Search },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Название сайта</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.siteName}
|
||||
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">URL сайта</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.siteUrl}
|
||||
onChange={(e) => setSettings({ ...settings, siteUrl: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">URL логотипа</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.logoUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, logoUrl: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">URL favicon</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.faviconUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, faviconUrl: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Описание</label>
|
||||
<textarea
|
||||
value={settings.description || ''}
|
||||
onChange={(e) => setSettings({ ...settings, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Email поддержки</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.supportEmail || ''}
|
||||
onChange={(e) => setSettings({ ...settings, supportEmail: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'features' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-4">
|
||||
{[
|
||||
{ key: 'enableRegistration', label: 'Регистрация', description: 'Разрешить новым пользователям регистрироваться' },
|
||||
{ key: 'enableDiscover', label: 'Discover', description: 'Раздел новостей и дайджестов' },
|
||||
{ key: 'enableFinance', label: 'Финансы', description: 'Финансовые инструменты и карта рынка' },
|
||||
{ key: 'enableLearning', label: 'Обучение', description: 'Интерактивные уроки' },
|
||||
{ key: 'enableTravel', label: 'Путешествия', description: 'Поиск авиабилетов и отелей' },
|
||||
{ key: 'enableMedicine', label: 'Медицина', description: 'Медицинский ассистент' },
|
||||
{ key: 'enableFileUploads', label: 'Загрузка файлов', description: 'Возможность прикреплять файлы к чатам' },
|
||||
{ key: 'maintenanceMode', label: 'Режим обслуживания', description: 'Временно закрыть доступ к сайту' },
|
||||
].map((feature) => (
|
||||
<div key={feature.key} className="flex items-center justify-between py-3 border-b border-border/20 last:border-0">
|
||||
<div>
|
||||
<p className="text-primary font-medium">{feature.label}</p>
|
||||
<p className="text-sm text-muted">{feature.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleFeature(feature.key as keyof FeatureFlags)}
|
||||
className={features[feature.key as keyof FeatureFlags] ? 'text-green-400' : 'text-muted'}
|
||||
>
|
||||
{features[feature.key as keyof FeatureFlags] ? (
|
||||
<ToggleRight className="w-8 h-8" />
|
||||
) : (
|
||||
<ToggleLeft className="w-8 h-8" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSaveFeatures}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'llm' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Провайдер по умолчанию</label>
|
||||
<select
|
||||
value={settings.llmSettings.defaultProvider}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
llmSettings: { ...settings.llmSettings, defaultProvider: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="timeweb">Timeweb Cloud</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Модель по умолчанию</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.llmSettings.defaultModel}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
llmSettings: { ...settings.llmSettings, defaultModel: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Максимум токенов</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.llmSettings.maxTokens}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
llmSettings: { ...settings.llmSettings, maxTokens: parseInt(e.target.value) || 4096 }
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">
|
||||
Temperature: {settings.llmSettings.temperature.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={settings.llmSettings.temperature}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
llmSettings: { ...settings.llmSettings, temperature: parseFloat(e.target.value) }
|
||||
})}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSaveLLM}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'search' && (
|
||||
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Поисковый движок</label>
|
||||
<select
|
||||
value={settings.searchSettings.defaultEngine}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
searchSettings: { ...settings.searchSettings, defaultEngine: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="searxng">SearXNG</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="bing">Bing</option>
|
||||
<option value="duckduckgo">DuckDuckGo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Максимум результатов</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.searchSettings.maxResults}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
searchSettings: { ...settings.searchSettings, maxResults: parseInt(e.target.value) || 10 }
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-primary font-medium">Безопасный поиск</p>
|
||||
<p className="text-sm text-muted">Фильтровать взрослый контент</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({
|
||||
...settings,
|
||||
searchSettings: { ...settings.searchSettings, safeSearch: !settings.searchSettings.safeSearch }
|
||||
})}
|
||||
className={settings.searchSettings.safeSearch ? 'text-green-400' : 'text-muted'}
|
||||
>
|
||||
{settings.searchSettings.safeSearch ? (
|
||||
<ToggleRight className="w-8 h-8" />
|
||||
) : (
|
||||
<ToggleLeft className="w-8 h-8" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSaveSearch}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
backend/webui/src/app/(main)/admin/users/page.tsx
Normal file
386
backend/webui/src/app/(main)/admin/users/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
fetchAdminUsers,
|
||||
createAdminUser,
|
||||
updateAdminUser,
|
||||
deleteAdminUser
|
||||
} from '@/lib/api';
|
||||
import type { AdminUser, UserRole, UserTier } from '@/lib/types';
|
||||
|
||||
interface UserModalProps {
|
||||
user?: AdminUser;
|
||||
onClose: () => void;
|
||||
onSave: (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => void;
|
||||
}
|
||||
|
||||
function UserModal({ user, onClose, onSave }: UserModalProps) {
|
||||
const [email, setEmail] = useState(user?.email || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState(user?.displayName || '');
|
||||
const [role, setRole] = useState<UserRole>(user?.role || 'user');
|
||||
const [tier, setTier] = useState<UserTier>(user?.tier || 'free');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
email,
|
||||
password: password || undefined,
|
||||
displayName,
|
||||
role,
|
||||
tier
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{user ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{!user && (
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required={!user}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Роль</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-secondary mb-1">Тариф</label>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as UserTier)}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="business">Business</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalUser, setModalUser] = useState<AdminUser | null | undefined>(undefined);
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAdminUsers(page, 20, search || undefined);
|
||||
setUsers(data.users);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
const handleSave = async (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => {
|
||||
try {
|
||||
if (modalUser) {
|
||||
await updateAdminUser(modalUser.id, data);
|
||||
} else {
|
||||
await createAdminUser({ ...data, password: data.password || '' });
|
||||
}
|
||||
setModalUser(undefined);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
console.error('Failed to save user:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Удалить пользователя?')) return;
|
||||
try {
|
||||
await deleteAdminUser(id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete user:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (user: AdminUser) => {
|
||||
try {
|
||||
await updateAdminUser(user.id, { isActive: !user.isActive });
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle user:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Пользователи</h1>
|
||||
<p className="text-muted">Управление пользователями платформы</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalUser(null)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl border border-border/30">
|
||||
<div className="p-4 border-b border-border/30">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по email или имени..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2 bg-base border border-border/50 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Пользователь</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Роль</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Тариф</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Статус</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Создан</th>
|
||||
<th className="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-border/20 hover:bg-base/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="w-10 h-10 rounded-full" />
|
||||
) : (
|
||||
<span className="text-primary font-medium">
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary font-medium">{user.displayName}</p>
|
||||
<p className="text-xs text-muted">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/10 text-purple-400'
|
||||
: 'bg-blue-500/10 text-blue-400'
|
||||
}`}>
|
||||
{user.role === 'admin' && <Crown className="w-3 h-3" />}
|
||||
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
user.tier === 'business'
|
||||
? 'bg-yellow-500/10 text-yellow-400'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-gray-500/10 text-gray-400'
|
||||
}`}>
|
||||
{user.tier.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
|
||||
user.isActive
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}>
|
||||
{user.isActive ? <UserCheck className="w-3 h-3" /> : <UserX className="w-3 h-3" />}
|
||||
{user.isActive ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{new Date(user.createdAt).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setActiveMenu(activeMenu === user.id ? null : user.id)}
|
||||
className="p-1 hover:bg-base rounded"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted" />
|
||||
</button>
|
||||
{activeMenu === user.id && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-surface border border-border/50 rounded-lg shadow-lg z-10 min-w-[150px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalUser(user);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleToggleActive(user);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
|
||||
>
|
||||
{user.isActive ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
||||
{user.isActive ? 'Деактивировать' : 'Активировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDelete(user.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
|
||||
<p className="text-sm text-muted">
|
||||
Показано {users.length} из {total}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-muted">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalUser !== undefined && (
|
||||
<UserModal
|
||||
user={modalUser || undefined}
|
||||
onClose={() => setModalUser(undefined)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,752 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Cpu,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Clock,
|
||||
Zap,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Database,
|
||||
Download,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Timer,
|
||||
DollarSign,
|
||||
Layers,
|
||||
Bot,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Settings2,
|
||||
Send,
|
||||
Globe,
|
||||
Code2,
|
||||
BarChart3,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Webhook,
|
||||
HardDrive,
|
||||
Menu,
|
||||
ArrowLeft,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ComputerTask,
|
||||
ComputerTaskStatus,
|
||||
DurationMode,
|
||||
Artifact,
|
||||
ComputerTaskEvent,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
executeComputerTask,
|
||||
fetchComputerTasks,
|
||||
cancelComputerTask,
|
||||
streamComputerTask,
|
||||
fetchComputerArtifacts,
|
||||
downloadArtifact,
|
||||
} from '@/lib/api';
|
||||
|
||||
const durationModes: { value: DurationMode; label: string; desc: string; icon: React.ElementType }[] = [
|
||||
{ value: 'short', label: '30 мин', desc: 'Быстрые', icon: Zap },
|
||||
{ value: 'medium', label: '4 часа', desc: 'Стандартные', icon: Clock },
|
||||
{ value: 'long', label: '24 часа', desc: 'Комплексные', icon: Calendar },
|
||||
{ value: 'extended', label: '7 дней', desc: 'Мониторинг', icon: Timer },
|
||||
{ value: 'unlimited', label: '∞', desc: 'Без лимита', icon: Sparkles },
|
||||
];
|
||||
|
||||
const taskExamples = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Исследование конкурентов',
|
||||
query: 'Проанализируй топ-5 конкурентов в сфере e-commerce в России',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
title: 'Разработка дашборда',
|
||||
query: 'Создай дашборд для отслеживания курсов криптовалют',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Мониторинг новостей',
|
||||
query: 'Мониторь новости по теме AI в медицине каждые 6 часов',
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Генерация отчёта',
|
||||
query: 'Исследуй рынок EdTech и создай PDF-отчёт с визуализациями',
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }> = {
|
||||
pending: { color: 'text-secondary', bg: 'bg-surface/60', icon: Clock, label: 'Ожидание' },
|
||||
planning: { color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Bot, label: 'Планирование' },
|
||||
executing: { color: 'text-success', bg: 'bg-success/10', icon: Play, label: 'Выполнение' },
|
||||
long_running: { color: 'text-success', bg: 'bg-success/10', icon: Loader2, label: 'Долгая задача' },
|
||||
waiting_user: { color: 'text-warning', bg: 'bg-warning/10', icon: AlertCircle, label: 'Ожидает ввода' },
|
||||
completed: { color: 'text-success', bg: 'bg-success/10', icon: CheckCircle2, label: 'Завершено' },
|
||||
failed: { color: 'text-error', bg: 'bg-error/10', icon: XCircle, label: 'Ошибка' },
|
||||
cancelled: { color: 'text-muted', bg: 'bg-surface/40', icon: Square, label: 'Отменено' },
|
||||
scheduled: { color: 'text-cyan-400', bg: 'bg-cyan-400/10', icon: Calendar, label: 'Запланировано' },
|
||||
paused: { color: 'text-warning', bg: 'bg-warning/10', icon: Pause, label: 'Пауза' },
|
||||
checkpoint: { color: 'text-gradient', bg: 'active-gradient', icon: RefreshCw, label: 'Чекпоинт' },
|
||||
};
|
||||
|
||||
const artifactIcons: Record<string, React.ElementType> = {
|
||||
file: FileText,
|
||||
code: FileCode,
|
||||
report: FileText,
|
||||
deployment: Globe,
|
||||
image: ImageIcon,
|
||||
data: Database,
|
||||
};
|
||||
|
||||
export default function ComputerPage() {
|
||||
const [tasks, setTasks] = useState<ComputerTask[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<ComputerTask | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [durationMode, setDurationMode] = useState<DurationMode>('medium');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||
const [events, setEvents] = useState<ComputerTaskEvent[]>([]);
|
||||
const [showTaskList, setShowTaskList] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const eventsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchComputerTasks(undefined, 50);
|
||||
setTasks(result.tasks || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
eventsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [events]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
setIsExecuting(true);
|
||||
setEvents([]);
|
||||
try {
|
||||
const task = await executeComputerTask({
|
||||
query: query.trim(),
|
||||
options: {
|
||||
async: true,
|
||||
durationMode,
|
||||
enableSandbox: true,
|
||||
enableBrowser: true,
|
||||
},
|
||||
});
|
||||
|
||||
setSelectedTask(task);
|
||||
setTasks((prev) => [task, ...prev]);
|
||||
setQuery('');
|
||||
setShowTaskList(false);
|
||||
|
||||
streamTaskEvents(task.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute task:', error);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const streamTaskEvents = async (taskId: string) => {
|
||||
try {
|
||||
for await (const event of streamComputerTask(taskId)) {
|
||||
setEvents((prev) => [...prev, event]);
|
||||
|
||||
if (event.status) {
|
||||
setSelectedTask((prev) =>
|
||||
prev?.id === taskId ? { ...prev, status: event.status!, progress: event.progress ?? prev.progress } : prev
|
||||
);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId ? { ...t, status: event.status!, progress: event.progress ?? t.progress } : t
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'task_completed' || event.type === 'task_failed') {
|
||||
loadArtifacts(taskId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadArtifacts = async (taskId: string) => {
|
||||
try {
|
||||
const result = await fetchComputerArtifacts(taskId);
|
||||
setArtifacts(result.artifacts || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load artifacts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (taskId: string) => {
|
||||
try {
|
||||
await cancelComputerTask(taskId);
|
||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: 'cancelled' } : t)));
|
||||
if (selectedTask?.id === taskId) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, status: 'cancelled' } : null));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (artifact: Artifact) => {
|
||||
try {
|
||||
const blob = await downloadArtifact(artifact.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = artifact.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download artifact:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExample = (exampleQuery: string) => {
|
||||
setQuery(exampleQuery);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSelectTask = (task: ComputerTask) => {
|
||||
setSelectedTask(task);
|
||||
setShowTaskList(false);
|
||||
if (isTaskActive(task.status)) {
|
||||
setEvents([]);
|
||||
streamTaskEvents(task.id);
|
||||
} else {
|
||||
loadArtifacts(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}мс`;
|
||||
if (ms < 60000) return `${Math.round(ms / 1000)}с`;
|
||||
if (ms < 3600000) return `${Math.round(ms / 60000)}м`;
|
||||
return `${Math.round(ms / 3600000)}ч`;
|
||||
};
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const isTaskActive = (status: ComputerTaskStatus): boolean => {
|
||||
return ['pending', 'planning', 'executing', 'long_running'].includes(status);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gradient-main relative">
|
||||
{/* Mobile Task List Overlay */}
|
||||
<AnimatePresence>
|
||||
{showTaskList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowTaskList(false)}
|
||||
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm md:hidden"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 z-50 w-[280px] bg-base border-r border-border/50 flex flex-col md:hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-border/50 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-gradient" />
|
||||
Задачи
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowTaskList(false)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-xl text-muted hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
selectedTask={selectedTask}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleSelectTask}
|
||||
isTaskActive={isTaskActive}
|
||||
statusConfig={statusConfig}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop Left Panel - Task List */}
|
||||
<div className="hidden md:flex w-72 lg:w-80 border-r border-border/50 flex-col">
|
||||
<div className="p-4 border-b border-border/50">
|
||||
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-gradient" />
|
||||
Computer
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-1">Автономные AI-задачи</p>
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
selectedTask={selectedTask}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleSelectTask}
|
||||
isTaskActive={isTaskActive}
|
||||
statusConfig={statusConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedTask ? (
|
||||
<motion.div
|
||||
key="task-detail"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Task Header */}
|
||||
<div className="p-4 sm:p-6 border-b border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedTask(null)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base sm:text-lg text-primary font-medium line-clamp-2">{selectedTask.query}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
|
||||
{(() => {
|
||||
const config = statusConfig[selectedTask.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 text-sm ${config.color}`}>
|
||||
<StatusIcon className={`w-4 h-4 ${isTaskActive(selectedTask.status) ? 'animate-spin' : ''}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="text-sm text-muted flex items-center gap-1">
|
||||
<Timer className="w-3.5 h-3.5" />
|
||||
{formatDuration(selectedTask.totalRuntime)}
|
||||
</span>
|
||||
<span className="text-sm text-muted flex items-center gap-1">
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
{formatCost(selectedTask.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isTaskActive(selectedTask.status) && (
|
||||
<button
|
||||
onClick={() => handleCancel(selectedTask.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-colors text-sm flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Отменить</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isTaskActive(selectedTask.status) && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-surface/60 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${selectedTask.progress}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="h-full progress-gradient rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-xs text-muted">Прогресс</span>
|
||||
<span className="text-xs text-gradient">{selectedTask.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
{/* Sub Tasks */}
|
||||
{selectedTask.subTasks && selectedTask.subTasks.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
Подзадачи ({selectedTask.subTasks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedTask.subTasks.map((subtask) => {
|
||||
const config = statusConfig[subtask.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="p-3 rounded-lg bg-elevated/40 border border-border/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${config.color} flex-shrink-0 ${
|
||||
isTaskActive(subtask.status) ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{subtask.description}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted uppercase">{subtask.type}</span>
|
||||
{subtask.cost > 0 && (
|
||||
<span className="text-xs text-muted">{formatCost(subtask.cost)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events Log */}
|
||||
{events.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
События
|
||||
</h3>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto bg-base/50 rounded-lg p-3 border border-border/30">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-faint font-mono whitespace-nowrap">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-secondary break-all">{event.message || event.type}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={eventsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artifacts */}
|
||||
{artifacts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
Артефакты ({artifacts.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{artifacts.map((artifact) => {
|
||||
const Icon = artifactIcons[artifact.type] || FileText;
|
||||
return (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="p-4 rounded-lg bg-elevated/40 border border-border/40 hover-gradient transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg icon-gradient flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-gradient" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{artifact.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted">{artifact.type}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{(artifact.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(artifact)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface/60 text-muted hover:text-secondary transition-colors flex-shrink-0"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="new-task"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8 pb-24">
|
||||
{/* Mobile task list button */}
|
||||
<div className="w-full max-w-2xl mb-6 md:hidden">
|
||||
<button
|
||||
onClick={() => setShowTaskList(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary bg-surface/40 border border-border/50 rounded-xl hover:border-border transition-all"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
Показать задачи ({tasks.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-10">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2 sm:mb-3">
|
||||
GooSeek <span className="text-gradient">Computer</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-secondary max-w-lg leading-relaxed px-4">
|
||||
Автономный AI-агент для сложных задач: исследования, код, мониторинг.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Input */}
|
||||
<div className="w-full max-w-2xl mb-6 sm:mb-8">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleExecute();
|
||||
}
|
||||
}}
|
||||
placeholder="Опишите задачу..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-4 pr-14 rounded-xl bg-elevated/60 border border-border text-primary placeholder-muted focus:outline-none input-gradient resize-none transition-all text-base"
|
||||
/>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!query.trim() || isExecuting}
|
||||
className={`absolute right-3 bottom-3 p-2.5 rounded-lg transition-all ${
|
||||
query.trim()
|
||||
? 'btn-gradient'
|
||||
: 'bg-surface/50 text-muted'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin btn-gradient-text" />
|
||||
) : (
|
||||
<Send className={`w-5 h-5 ${query.trim() ? 'btn-gradient-text' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Mode Selector */}
|
||||
<div className="w-full max-w-2xl mb-6 sm:mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-secondary">Режим выполнения</span>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-xs text-muted hover:text-secondary flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Расширенные</span>
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{durationModes.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setDurationMode(mode.value)}
|
||||
className={`p-2 sm:p-3 rounded-xl text-center transition-all ${
|
||||
durationMode === mode.value
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border border-border/40 text-secondary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<mode.icon className={`w-4 sm:w-5 h-4 sm:h-5 mx-auto mb-1 ${
|
||||
durationMode === mode.value ? 'text-gradient' : 'text-muted'
|
||||
}`} />
|
||||
<div className={`text-xs sm:text-sm font-medium ${durationMode === mode.value ? 'text-gradient' : ''}`}>{mode.label}</div>
|
||||
<div className="text-xs text-muted mt-0.5 hidden sm:block">{mode.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<AnimatePresence>
|
||||
{showAdvanced && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="w-full max-w-2xl mb-6 sm:mb-8 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 rounded-xl bg-elevated/40 border border-border/40">
|
||||
<h4 className="text-sm font-medium text-primary mb-3">Коннекторы</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ icon: Mail, label: 'Email', desc: 'Отправка' },
|
||||
{ icon: MessageCircle, label: 'Telegram', desc: 'Уведомления' },
|
||||
{ icon: Webhook, label: 'Webhook', desc: 'HTTP' },
|
||||
{ icon: HardDrive, label: 'Storage', desc: 'S3' },
|
||||
].map((conn) => (
|
||||
<button
|
||||
key={conn.label}
|
||||
className="p-3 rounded-lg bg-surface/40 border border-border/50 hover-gradient transition-colors text-left"
|
||||
>
|
||||
<conn.icon className="w-4 h-4 text-gradient mb-1.5" />
|
||||
<div className="text-xs text-primary">{conn.label}</div>
|
||||
<div className="text-xs text-muted hidden sm:block">{conn.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Examples */}
|
||||
<div className="w-full max-w-2xl lg:max-w-3xl">
|
||||
<h3 className="text-sm text-muted mb-3 text-center">Примеры задач</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{taskExamples.map((example, i) => (
|
||||
<motion.button
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => handleExample(example.query)}
|
||||
className="group relative p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover-gradient text-left transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${example.bgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<example.icon className={`w-4 h-4 ${example.color}`} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">{example.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary line-clamp-2 leading-relaxed">{example.query}</p>
|
||||
<ArrowRight className="absolute bottom-3 right-3 w-4 h-4 text-transparent group-hover:text-gradient opacity-60 transition-all transform translate-x-2 group-hover:translate-x-0" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: ComputerTask[];
|
||||
selectedTask: ComputerTask | null;
|
||||
isLoading: boolean;
|
||||
onSelect: (task: ComputerTask) => void;
|
||||
isTaskActive: (status: ComputerTaskStatus) => boolean;
|
||||
statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }>;
|
||||
}
|
||||
|
||||
function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, statusConfig }: TaskListProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 loader-gradient animate-spin" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted text-sm">
|
||||
Нет активных задач
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const config = statusConfig[task.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<motion.button
|
||||
key={task.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={() => onSelect(task)}
|
||||
className={`w-full text-left p-3 rounded-xl transition-all ${
|
||||
selectedTask?.id === task.id
|
||||
? 'bg-elevated/60 active-gradient'
|
||||
: 'bg-elevated/40 border border-border/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-1.5 rounded-lg ${config.bg} flex-shrink-0`}>
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${config.color} ${
|
||||
isTaskActive(task.status) ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary line-clamp-2">{task.query}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-xs ${config.color}`}>{config.label}</span>
|
||||
{task.progress > 0 && task.progress < 100 && (
|
||||
<span className="text-xs text-muted">{task.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{isTaskActive(task.status) && task.progress > 0 && (
|
||||
<div className="mt-2 h-1 bg-surface/60 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${task.progress}%` }}
|
||||
className="h-full progress-gradient"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign, Dumbbell } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign,
|
||||
Dumbbell, Landmark, FlaskConical, Heart, Clapperboard, Briefcase,
|
||||
Palette, ChevronLeft, ChevronRight
|
||||
} from 'lucide-react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { fetchDiscover } from '@/lib/api';
|
||||
import type { DiscoverItem } from '@/lib/types';
|
||||
@@ -12,37 +16,122 @@ const topics = [
|
||||
{ id: 'tech', label: 'Технологии', icon: Cpu },
|
||||
{ id: 'finance', label: 'Финансы', icon: DollarSign },
|
||||
{ id: 'sports', label: 'Спорт', icon: Dumbbell },
|
||||
{ id: 'politics', label: 'Политика', icon: Landmark },
|
||||
{ id: 'science', label: 'Наука', icon: FlaskConical },
|
||||
{ id: 'health', label: 'Здоровье', icon: Heart },
|
||||
{ id: 'entertainment', label: 'Развлечения', icon: Clapperboard },
|
||||
{ id: 'world', label: 'В мире', icon: Globe },
|
||||
{ id: 'business', label: 'Бизнес', icon: Briefcase },
|
||||
{ id: 'culture', label: 'Культура', icon: Palette },
|
||||
] as const;
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export default function DiscoverPage() {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<DiscoverItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [topic, setTopic] = useState<string>('tech');
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = tabsRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
|
||||
}, []);
|
||||
|
||||
const scrollTabs = useCallback((direction: 'left' | 'right') => {
|
||||
const el = tabsRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = 200;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
window.addEventListener('resize', checkScroll);
|
||||
return () => window.removeEventListener('resize', checkScroll);
|
||||
}, [checkScroll]);
|
||||
|
||||
const handleSummarize = useCallback((url: string) => {
|
||||
const query = encodeURIComponent(`Summary: ${url}`);
|
||||
router.push(`/?q=${query}`);
|
||||
}, [router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadInitial = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
try {
|
||||
const data = await fetchDiscover(topic, 'russia');
|
||||
setItems(data);
|
||||
const response = await fetchDiscover(topic, 'russia', 1, ITEMS_PER_PAGE);
|
||||
setItems(response.blogs);
|
||||
setHasMore(response.hasMore);
|
||||
setPage(1);
|
||||
} catch {
|
||||
setItems([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [topic]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const response = await fetchDiscover(topic, 'russia', nextPage, ITEMS_PER_PAGE);
|
||||
|
||||
if (response.blogs.length > 0) {
|
||||
setItems(prev => [...prev, ...response.blogs]);
|
||||
setPage(nextPage);
|
||||
}
|
||||
setHasMore(response.hasMore);
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [topic, page, hasMore, isLoadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading && !isLoadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '100px' }
|
||||
);
|
||||
|
||||
const el = loadMoreRef.current;
|
||||
if (el) observer.observe(el);
|
||||
|
||||
return () => {
|
||||
if (el) observer.unobserve(el);
|
||||
};
|
||||
}, [hasMore, isLoading, isLoadingMore, loadMore]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div ref={containerRef} className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
@@ -51,7 +140,7 @@ export default function DiscoverPage() {
|
||||
<p className="text-sm text-secondary">Актуальные новости и события</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
onClick={loadInitial}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover-gradient rounded-xl transition-all w-fit"
|
||||
>
|
||||
@@ -60,20 +149,52 @@ export default function DiscoverPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Topic Tabs */}
|
||||
{/* Topic Tabs Carousel */}
|
||||
<Tabs.Root value={topic} onValueChange={setTopic}>
|
||||
<Tabs.List className="flex gap-2 mb-6 sm:mb-8 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{topics.map((t) => (
|
||||
<Tabs.Trigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:active-gradient data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||||
<div className="relative mb-6 sm:mb-8">
|
||||
{/* Left scroll button */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scrollTabs('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-8 h-8 flex items-center justify-center bg-background/90 backdrop-blur-sm border border-border/50 rounded-full shadow-lg hover:bg-surface transition-colors"
|
||||
aria-label="Прокрутить влево"
|
||||
>
|
||||
<t.icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<ChevronLeft className="w-4 h-4 text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tabs container */}
|
||||
<div
|
||||
ref={tabsRef}
|
||||
onScroll={checkScroll}
|
||||
className="overflow-x-auto scrollbar-hide scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<Tabs.List className="flex gap-2 px-1 py-1 w-max">
|
||||
{topics.map((t) => (
|
||||
<Tabs.Trigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:active-gradient data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||||
>
|
||||
<t.icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scrollTabs('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-8 h-8 flex items-center justify-center bg-background/90 backdrop-blur-sm border border-border/50 rounded-full shadow-lg hover:bg-surface transition-colors"
|
||||
aria-label="Прокрутить вправо"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
{/* Content */}
|
||||
@@ -94,10 +215,10 @@ export default function DiscoverPage() {
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{items.map((item, i) => (
|
||||
<motion.article
|
||||
key={i}
|
||||
key={`${item.url}-${i}`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
transition={{ delay: Math.min(i * 0.05, 0.5) }}
|
||||
className="group relative flex flex-col sm:flex-row gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover-gradient hover:bg-elevated/60 transition-all duration-200"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
@@ -155,6 +276,21 @@ export default function DiscoverPage() {
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
|
||||
{/* Infinite scroll trigger & loading indicator */}
|
||||
<div ref={loadMoreRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted" />
|
||||
<span className="text-sm text-muted">Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && items.length > 0 && (
|
||||
<p className="text-center text-sm text-muted py-4">
|
||||
Все новости загружены
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-primary">GooSeek</span>
|
||||
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
298
backend/webui/src/app/(main)/medicine/page.tsx
Normal file
298
backend/webui/src/app/(main)/medicine/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
HeartPulse,
|
||||
Search,
|
||||
Pill,
|
||||
Stethoscope,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Activity,
|
||||
Thermometer,
|
||||
Brain,
|
||||
Eye,
|
||||
Bone,
|
||||
Wind,
|
||||
Droplet,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Symptom {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
readTime: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const commonSymptoms: Symptom[] = [
|
||||
{ id: '1', name: 'Головная боль', icon: Brain, category: 'Неврология' },
|
||||
{ id: '2', name: 'Температура', icon: Thermometer, category: 'Общие' },
|
||||
{ id: '3', name: 'Боль в горле', icon: Wind, category: 'ЛОР' },
|
||||
{ id: '4', name: 'Боль в спине', icon: Bone, category: 'Ортопедия' },
|
||||
{ id: '5', name: 'Проблемы со зрением', icon: Eye, category: 'Офтальмология' },
|
||||
{ id: '6', name: 'Давление', icon: Activity, category: 'Кардиология' },
|
||||
{ id: '7', name: 'Аллергия', icon: Droplet, category: 'Аллергология' },
|
||||
{ id: '8', name: 'Усталость', icon: HeartPulse, category: 'Общие' },
|
||||
];
|
||||
|
||||
const healthArticles: Article[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Как укрепить иммунитет зимой',
|
||||
category: 'Профилактика',
|
||||
readTime: '5 мин',
|
||||
icon: '🛡️',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Правильное питание для сердца',
|
||||
category: 'Кардиология',
|
||||
readTime: '7 мин',
|
||||
icon: '❤️',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Упражнения для здоровой спины',
|
||||
category: 'Ортопедия',
|
||||
readTime: '6 мин',
|
||||
icon: '🏃',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Как справиться со стрессом',
|
||||
category: 'Психология',
|
||||
readTime: '8 мин',
|
||||
icon: '🧘',
|
||||
},
|
||||
];
|
||||
|
||||
const quickServices = [
|
||||
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-400' },
|
||||
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-400' },
|
||||
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-400' },
|
||||
{ icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' },
|
||||
];
|
||||
|
||||
function SymptomButton({ symptom, onClick }: { symptom: Symptom; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg hover:border-border hover:bg-surface/70 transition-all text-left"
|
||||
>
|
||||
<symptom.icon className="w-4 h-4 text-muted flex-shrink-0" />
|
||||
<span className="text-sm text-secondary truncate">{symptom.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleCard({ article, delay }: { article: Article; delay: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay }}
|
||||
className="flex items-center gap-3 p-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-surface/60 flex items-center justify-center text-xl flex-shrink-0">
|
||||
{article.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
|
||||
{article.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span>{article.category}</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{article.readTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicinePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [aiResponse, setAiResponse] = useState<string | null>(null);
|
||||
|
||||
const handleAIConsult = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setIsLoading(true);
|
||||
setAiResponse(null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setAiResponse(
|
||||
'На основе описанных симптомов рекомендую обратиться к терапевту для первичного осмотра. ' +
|
||||
'Это может быть связано с несколькими причинами, которые требуют диагностики. ' +
|
||||
'До визита к врачу: пейте больше жидкости, отдыхайте, избегайте переохлаждения.'
|
||||
);
|
||||
setIsLoading(false);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSymptomClick = (symptomName: string) => {
|
||||
setSearchQuery(symptomName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Медицина</h1>
|
||||
<p className="text-sm text-secondary">AI-помощник по здоровью и медицине</p>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl mb-6">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-400 mb-1">Важно</p>
|
||||
<p className="text-xs text-muted">
|
||||
Информация носит справочный характер и не заменяет консультацию врача.
|
||||
При серьёзных симптомах обратитесь к специалисту.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Search */}
|
||||
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
|
||||
<div className="relative mb-4">
|
||||
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAIConsult()}
|
||||
placeholder="Опишите симптомы или задайте вопрос о здоровье..."
|
||||
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAIConsult}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
AI анализирует...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4" />
|
||||
Получить консультацию AI
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AI Response */}
|
||||
{aiResponse && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-surface/50 border border-border/40 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4 text-gradient" />
|
||||
<span className="text-xs font-medium text-gradient">Рекомендация AI</span>
|
||||
</div>
|
||||
<p className="text-sm text-secondary leading-relaxed">{aiResponse}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Services */}
|
||||
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
|
||||
{quickServices.map((service) => (
|
||||
<button
|
||||
key={service.label}
|
||||
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${service.color}`}
|
||||
>
|
||||
<service.icon className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<span className="text-[10px] sm:text-xs font-medium text-center leading-tight">
|
||||
{service.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Common Symptoms */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center">
|
||||
<HeartPulse className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{commonSymptoms.map((symptom) => (
|
||||
<SymptomButton
|
||||
key={symptom.id}
|
||||
symptom={symptom}
|
||||
onClick={() => handleSymptomClick(symptom.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Articles */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-green-500/10 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{healthArticles.map((article, i) => (
|
||||
<ArticleCard key={article.id} article={article} delay={i * 0.05} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Info */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm font-medium text-red-400">Экстренная помощь</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-3">
|
||||
При угрозе жизни немедленно вызывайте скорую помощь
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="tel:103"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 103
|
||||
</a>
|
||||
<a
|
||||
href="tel:112"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 112
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState, Suspense } from 'react';
|
||||
import { useRef, useEffect, useState, Suspense, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { ChatInput, SendOptions } from '@/components/ChatInput';
|
||||
import { ChatMessage } from '@/components/ChatMessage';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
function HomePageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
|
||||
const { messages, isLoading, sendMessage: sendChatMessage, stopGeneration } = useChat();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [showWelcome, setShowWelcome] = useState(true);
|
||||
const initialQueryProcessed = useRef(false);
|
||||
|
||||
const handleSend = useCallback((message: string, options: SendOptions) => {
|
||||
sendChatMessage(message, options);
|
||||
}, [sendChatMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setShowWelcome(false);
|
||||
@@ -26,9 +30,9 @@ function HomePageContent() {
|
||||
if (query && !initialQueryProcessed.current && !isLoading) {
|
||||
initialQueryProcessed.current = true;
|
||||
router.replace('/', { scroll: false });
|
||||
sendMessage(decodeURIComponent(query));
|
||||
sendChatMessage(decodeURIComponent(query));
|
||||
}
|
||||
}, [searchParams, isLoading, sendMessage, router]);
|
||||
}, [searchParams, isLoading, sendChatMessage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -68,7 +72,7 @@ function HomePageContent() {
|
||||
className="w-full max-w-2xl px-2"
|
||||
>
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
isLoading={isLoading}
|
||||
placeholder="Спросите что угодно..."
|
||||
@@ -106,7 +110,7 @@ function HomePageContent() {
|
||||
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 sm:pb-6 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
isLoading={isLoading}
|
||||
placeholder="Продолжить диалог..."
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages } from 'lucide-react';
|
||||
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages, Plug, ChevronDown } from 'lucide-react';
|
||||
import * as Switch from '@radix-ui/react-switch';
|
||||
import { useLanguage, SupportedLanguage } from '@/lib/contexts/LanguageContext';
|
||||
import { ConnectorsSettings } from '@/components/settings/ConnectorsSettings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [mode, setMode] = useState('balanced');
|
||||
const [saveHistory, setSaveHistory] = useState(true);
|
||||
const [personalization, setPersonalization] = useState(true);
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
const [language, setLanguage] = useState('ru');
|
||||
const [showConnectors, setShowConnectors] = useState(false);
|
||||
const { language, setLanguage } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
@@ -83,7 +86,7 @@ export default function SettingsPage() {
|
||||
].map((lang) => (
|
||||
<button
|
||||
key={lang.id}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
onClick={() => setLanguage(lang.id as SupportedLanguage)}
|
||||
className={`flex items-center gap-3 p-3 sm:p-4 rounded-xl border transition-all ${
|
||||
language === lang.id
|
||||
? 'active-gradient text-primary'
|
||||
@@ -97,6 +100,40 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Connectors */}
|
||||
<Section title="Коннекторы" icon={Plug}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted">
|
||||
Подключите источники данных и каналы уведомлений для автономных задач в Лаптопе.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowConnectors(!showConnectors)}
|
||||
className="w-full flex items-center justify-between p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center">
|
||||
<Plug className="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-primary">Настроить коннекторы</p>
|
||||
<p className="text-xs text-muted">Поиск, данные, финансы, уведомления</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-5 h-5 text-muted transition-transform ${showConnectors ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showConnectors && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="pt-2"
|
||||
>
|
||||
<ConnectorsSettings />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Data Management */}
|
||||
<Section title="Управление данными" icon={Download}>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
|
||||
316
backend/webui/src/app/(main)/travel/page.tsx
Normal file
316
backend/webui/src/app/(main)/travel/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Plane,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Users,
|
||||
Search,
|
||||
Star,
|
||||
Clock,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Globe,
|
||||
Hotel,
|
||||
Car,
|
||||
Palmtree,
|
||||
Mountain,
|
||||
Building2,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
image: string;
|
||||
rating: number;
|
||||
price: string;
|
||||
category: 'beach' | 'city' | 'nature' | 'mountain';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const popularDestinations: Destination[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Сочи',
|
||||
country: 'Россия',
|
||||
image: '🏖️',
|
||||
rating: 4.7,
|
||||
price: 'от 15 000 ₽',
|
||||
category: 'beach',
|
||||
description: 'Черноморский курорт с горами и морем',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Санкт-Петербург',
|
||||
country: 'Россия',
|
||||
image: '🏛️',
|
||||
rating: 4.9,
|
||||
price: 'от 8 000 ₽',
|
||||
category: 'city',
|
||||
description: 'Культурная столица России',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Казань',
|
||||
country: 'Россия',
|
||||
image: '🕌',
|
||||
rating: 4.6,
|
||||
price: 'от 7 500 ₽',
|
||||
category: 'city',
|
||||
description: 'Древний город на Волге',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Байкал',
|
||||
country: 'Россия',
|
||||
image: '🏔️',
|
||||
rating: 4.8,
|
||||
price: 'от 25 000 ₽',
|
||||
category: 'nature',
|
||||
description: 'Самое глубокое озеро в мире',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Калининград',
|
||||
country: 'Россия',
|
||||
image: '⚓',
|
||||
rating: 4.5,
|
||||
price: 'от 12 000 ₽',
|
||||
category: 'city',
|
||||
description: 'Европейский колорит на Балтике',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Алтай',
|
||||
country: 'Россия',
|
||||
image: '🗻',
|
||||
rating: 4.7,
|
||||
price: 'от 20 000 ₽',
|
||||
category: 'mountain',
|
||||
description: 'Горные пейзажи и чистый воздух',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Все', icon: Globe },
|
||||
{ id: 'beach', label: 'Пляж', icon: Waves },
|
||||
{ id: 'city', label: 'Города', icon: Building2 },
|
||||
{ id: 'nature', label: 'Природа', icon: Palmtree },
|
||||
{ id: 'mountain', label: 'Горы', icon: Mountain },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ icon: Plane, label: 'Авиабилеты', color: 'bg-blue-500/10 text-blue-400' },
|
||||
{ icon: Hotel, label: 'Отели', color: 'bg-purple-500/10 text-purple-400' },
|
||||
{ icon: Car, label: 'Авто', color: 'bg-green-500/10 text-green-400' },
|
||||
{ icon: Sparkles, label: 'AI Планировщик', color: 'active-gradient text-gradient' },
|
||||
];
|
||||
|
||||
function DestinationCard({ destination, delay }: { destination: Destination; delay: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
className="group bg-elevated/40 border border-border/40 rounded-xl overflow-hidden hover:border-border transition-all cursor-pointer"
|
||||
>
|
||||
<div className="h-28 sm:h-32 bg-surface/60 flex items-center justify-center text-4xl sm:text-5xl">
|
||||
{destination.image}
|
||||
</div>
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors">
|
||||
{destination.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{destination.country}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Star className="w-3 h-3 text-amber-400 fill-amber-400" />
|
||||
<span className="text-secondary">{destination.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-3 line-clamp-2">{destination.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gradient">{destination.price}</span>
|
||||
<button className="text-xs text-muted hover:text-primary transition-colors">
|
||||
Подробнее →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TravelPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [travelers, setTravelers] = useState(2);
|
||||
const [departureDate, setDepartureDate] = useState('');
|
||||
|
||||
const handleAISearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setIsLoading(false);
|
||||
}, [searchQuery]);
|
||||
|
||||
const filteredDestinations = popularDestinations.filter((d) => {
|
||||
if (selectedCategory !== 'all' && d.category !== selectedCategory) return false;
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
d.name.toLowerCase().includes(query) ||
|
||||
d.country.toLowerCase().includes(query) ||
|
||||
d.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Путешествия</h1>
|
||||
<p className="text-sm text-secondary">Планируйте поездки с помощью AI</p>
|
||||
</div>
|
||||
|
||||
{/* AI Search */}
|
||||
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
|
||||
<div className="relative mb-4">
|
||||
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAISearch()}
|
||||
placeholder="Куда хотите поехать? Опишите своё идеальное путешествие..."
|
||||
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-muted" />
|
||||
<input
|
||||
type="date"
|
||||
value={departureDate}
|
||||
onChange={(e) => setDepartureDate(e.target.value)}
|
||||
className="bg-transparent text-sm text-secondary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg">
|
||||
<Users className="w-4 h-4 text-muted" />
|
||||
<select
|
||||
value={travelers}
|
||||
onChange={(e) => setTravelers(Number(e.target.value))}
|
||||
className="bg-transparent text-sm text-secondary outline-none"
|
||||
>
|
||||
<option value={1}>1 человек</option>
|
||||
<option value={2}>2 человека</option>
|
||||
<option value={3}>3 человека</option>
|
||||
<option value={4}>4 человека</option>
|
||||
<option value={5}>5+ человек</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAISearch}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
AI планирует маршрут...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4" />
|
||||
Найти с помощью AI
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${action.color}`}
|
||||
>
|
||||
<action.icon className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<span className="text-[10px] sm:text-xs font-medium">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap ${
|
||||
selectedCategory === cat.id
|
||||
? 'active-gradient text-primary border-transparent'
|
||||
: 'bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<cat.icon className="w-4 h-4" />
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Popular Destinations */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl active-gradient flex items-center justify-center">
|
||||
<Plane className="w-4 h-4" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Популярные направления</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
{filteredDestinations.map((dest, i) => (
|
||||
<DestinationCard key={dest.id} destination={dest} delay={i * 0.05} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredDestinations.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<MapPin className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary">Направления не найдены</p>
|
||||
<p className="text-sm text-muted mt-1">Попробуйте изменить фильтры</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Tips */}
|
||||
<div className="bg-surface/30 border border-border/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-4 h-4 text-muted" />
|
||||
<span className="text-xs font-medium text-secondary">Совет AI</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Опишите ваши предпочтения в поиске: бюджет, тип отдыха, даты — и AI составит
|
||||
оптимальный маршрут с учётом всех пожеланий.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
17
backend/webui/src/app/providers.tsx
Normal file
17
backend/webui/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
72
backend/webui/src/components/auth/AuthModal.tsx
Normal file
72
backend/webui/src/components/auth/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
backend/webui/src/components/auth/ForgotPasswordForm.tsx
Normal file
132
backend/webui/src/components/auth/ForgotPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
backend/webui/src/components/auth/LoginForm.tsx
Normal file
148
backend/webui/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
backend/webui/src/components/auth/RegisterForm.tsx
Normal file
252
backend/webui/src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
backend/webui/src/components/auth/UserMenu.tsx
Normal file
140
backend/webui/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
backend/webui/src/components/auth/index.ts
Normal file
5
backend/webui/src/components/auth/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
695
backend/webui/src/components/settings/ConnectorsSettings.tsx
Normal file
695
backend/webui/src/components/settings/ConnectorsSettings.tsx
Normal 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;
|
||||
@@ -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)}®ion=${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();
|
||||
|
||||
276
backend/webui/src/lib/auth.ts
Normal file
276
backend/webui/src/lib/auth.ts
Normal 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();
|
||||
}
|
||||
56
backend/webui/src/lib/config/menu.ts
Normal file
56
backend/webui/src/lib/config/menu.ts
Normal 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);
|
||||
}
|
||||
141
backend/webui/src/lib/contexts/AuthContext.tsx
Normal file
141
backend/webui/src/lib/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
61
backend/webui/src/lib/contexts/LanguageContext.tsx
Normal file
61
backend/webui/src/lib/contexts/LanguageContext.tsx
Normal 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';
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user