Update Next.js configuration for S3 support and enhance admin dashboard functionality - Added S3 hostname to next.config.js for image uploads - Updated package.json and package-lock.json with AWS SDK dependencies - Improved admin layout with S3 status component and enhanced dashboard statistics loading logic - Refactored news loading in NewsBlock component to handle errors gracefully.
This commit is contained in:
@ -51,21 +51,30 @@ export default function ImageUpload({
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// В реальном приложении здесь будет загрузка на сервер
|
||||
// Для демонстрации используем FileReader для создания data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
onChange(result);
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('folder', 'images');
|
||||
|
||||
// Если есть старое изображение, передаем его URL для удаления
|
||||
if (value && value.startsWith('http')) {
|
||||
formData.append('oldUrl', value);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Ошибка при загрузке файла');
|
||||
}
|
||||
|
||||
onChange(result.data.publicUrl);
|
||||
setIsUploading(false);
|
||||
} catch (error) {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setError(error instanceof Error ? error.message : 'Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
@ -97,7 +106,19 @@ export default function ImageUpload({
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
// Если файл находится в S3, удаляем его оттуда
|
||||
if (value && value.startsWith('http')) {
|
||||
await fetch(`/api/upload?url=${encodeURIComponent(value)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении файла:', error);
|
||||
// Не показываем ошибку пользователю, так как файл может быть удален из UI
|
||||
}
|
||||
|
||||
onRemove();
|
||||
setError('');
|
||||
if (fileInputRef.current) {
|
||||
|
141
app/admin/components/S3Status.tsx
Normal file
141
app/admin/components/S3Status.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircle, XCircle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface S3StatusProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function S3Status({ className = '' }: S3StatusProps) {
|
||||
const [status, setStatus] = useState<'checking' | 'connected' | 'error' | 'unknown'>('unknown');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
console.log('S3Status: Компонент инициализирован (версия 2.0)');
|
||||
|
||||
const checkS3Connection = async () => {
|
||||
setIsChecking(true);
|
||||
setStatus('checking');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
console.log('S3Status: Проверяю подключение к S3...');
|
||||
console.log('S3Status: Используется API /api/test-s3');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 секунд таймаут
|
||||
|
||||
const response = await fetch(`/api/test-s3?t=${Date.now()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
console.log('S3Status: Запрос отправлен к /api/test-s3, статус:', response.status);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const result = await response.json();
|
||||
|
||||
console.log('S3Status: Результат проверки:', result);
|
||||
|
||||
if (result.success) {
|
||||
setStatus('connected');
|
||||
console.log('S3Status: Подключение успешно');
|
||||
} else {
|
||||
setStatus('error');
|
||||
const errorMessage = result.error || 'Ошибка подключения к S3';
|
||||
setError(errorMessage);
|
||||
console.error('S3Status: Ошибка подключения:', result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('S3Status: Исключение при проверке:', err);
|
||||
setStatus('error');
|
||||
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setError('Таймаут подключения к S3 (более 10 секунд)');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка сети при проверке S3');
|
||||
}
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkS3Connection();
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return <RefreshCw className="h-4 w-4 animate-spin text-blue-500" />;
|
||||
case 'connected':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'Проверка подключения...';
|
||||
case 'connected':
|
||||
return 'S3 подключено';
|
||||
case 'error':
|
||||
return 'Ошибка S3';
|
||||
default:
|
||||
return 'Статус неизвестен';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'text-blue-400';
|
||||
case 'connected':
|
||||
return 'text-green-400';
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
{getStatusIcon()}
|
||||
<span className={`text-sm font-medium ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
|
||||
{status === 'error' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkS3Connection}
|
||||
disabled={isChecking}
|
||||
className="ml-2"
|
||||
>
|
||||
{isChecking ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
'Повторить'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="ml-2 text-xs text-red-400 max-w-xs" title={error}>
|
||||
<span className="block truncate">{error}</span>
|
||||
<span className="block text-xs text-gray-500 mt-1">
|
||||
Проверьте консоль для подробностей
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Building, FileText, Settings, LogOut, Menu, X } from 'lucide-react';
|
||||
import S3Status from './components/S3Status';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -69,17 +70,17 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
||||
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-gradient-to-b from-gray-900 to-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b">
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-700">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<Building className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-xl font-bold text-gray-900">Admin Panel</span>
|
||||
<Building className="h-8 w-8 text-blue-400" />
|
||||
<span className="text-xl font-bold text-white">CKE Admin</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-700 text-gray-300 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@ -92,10 +93,10 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center px-6 py-3 text-sm font-medium transition-colors ${
|
||||
className={`flex items-center px-6 py-3 text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
? 'bg-blue-600 text-white border-r-4 border-blue-400 shadow-lg'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
@ -106,10 +107,13 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 w-full p-6 border-t">
|
||||
<div className="absolute bottom-0 w-full p-6 border-t border-gray-700 space-y-4">
|
||||
<div className="bg-gray-800 rounded-lg p-3">
|
||||
<S3Status />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900 hover:bg-opacity-20 rounded-md transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Выйти
|
||||
@ -120,17 +124,22 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
{/* Main content */}
|
||||
<div className="lg:ml-64">
|
||||
{/* Top bar */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-600"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500">Добро пожаловать, admin</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">A</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 font-medium">Добро пожаловать, admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -166,11 +175,15 @@ function LoginForm({ onLogin }: { onLogin: (username: string, password: string)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<Building className="h-12 w-12 text-blue-600 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">Административная панель</h1>
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-blue-700 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Building className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
|
||||
CKE Admin Panel
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Войдите в систему управления</p>
|
||||
</div>
|
||||
|
||||
|
@ -1,68 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Eye, Calendar, TrendingUp, Plus } from 'lucide-react';
|
||||
import { NEWS_DATA } from '@/lib/news-data';
|
||||
import { FileText, Eye, Calendar, TrendingUp, Plus, Users, Database, Activity } from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
totalNews: number;
|
||||
publishedNews: number;
|
||||
featuredNews: number;
|
||||
recentNews: number;
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
category: string;
|
||||
featured: boolean;
|
||||
published: boolean;
|
||||
publishedAt: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
// Подсчет статистики
|
||||
const totalNews = NEWS_DATA.length;
|
||||
const publishedNews = NEWS_DATA.filter(news => news.published !== false).length;
|
||||
const featuredNews = NEWS_DATA.filter(news => news.featured).length;
|
||||
const recentNews = NEWS_DATA.filter(news => {
|
||||
const publishDate = new Date(news.publishedAt);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
return publishDate >= weekAgo;
|
||||
}).length;
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [latestNews, setLatestNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const stats = [
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем статистику
|
||||
const healthResponse = await fetch('/api/health');
|
||||
const healthData = await healthResponse.json();
|
||||
|
||||
// Загружаем все новости для статистики
|
||||
const newsResponse = await fetch('/api/news?limit=100&published=all');
|
||||
const newsData = await newsResponse.json();
|
||||
|
||||
if (newsData.success) {
|
||||
const allNews = newsData.data.news;
|
||||
const publishedNews = allNews.filter((news: NewsItem) => news.published);
|
||||
const featuredNews = allNews.filter((news: NewsItem) => news.featured);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const recentNews = allNews.filter((news: NewsItem) => {
|
||||
const publishDate = new Date(news.publishedAt);
|
||||
return publishDate >= weekAgo;
|
||||
});
|
||||
|
||||
setStats({
|
||||
totalNews: allNews.length,
|
||||
publishedNews: publishedNews.length,
|
||||
featuredNews: featuredNews.length,
|
||||
recentNews: recentNews.length,
|
||||
totalUsers: healthData.data?.userCount || 0
|
||||
});
|
||||
|
||||
// Берем последние 5 новостей
|
||||
setLatestNews(allNews.slice(0, 5));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statsCards = stats ? [
|
||||
{
|
||||
name: 'Всего новостей',
|
||||
value: totalNews,
|
||||
value: stats.totalNews,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
textColor: 'text-blue-600'
|
||||
color: 'bg-gradient-to-r from-blue-500 to-blue-600',
|
||||
textColor: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50'
|
||||
},
|
||||
{
|
||||
name: 'Опубликовано',
|
||||
value: publishedNews,
|
||||
value: stats.publishedNews,
|
||||
icon: Eye,
|
||||
color: 'bg-green-500',
|
||||
textColor: 'text-green-600'
|
||||
color: 'bg-gradient-to-r from-green-500 to-green-600',
|
||||
textColor: 'text-green-600',
|
||||
bgColor: 'bg-green-50'
|
||||
},
|
||||
{
|
||||
name: 'Рекомендуемые',
|
||||
value: featuredNews,
|
||||
value: stats.featuredNews,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-yellow-500',
|
||||
textColor: 'text-yellow-600'
|
||||
color: 'bg-gradient-to-r from-yellow-500 to-yellow-600',
|
||||
textColor: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50'
|
||||
},
|
||||
{
|
||||
name: 'За неделю',
|
||||
value: recentNews,
|
||||
value: stats.recentNews,
|
||||
icon: Calendar,
|
||||
color: 'bg-purple-500',
|
||||
textColor: 'text-purple-600'
|
||||
color: 'bg-gradient-to-r from-purple-500 to-purple-600',
|
||||
textColor: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50'
|
||||
},
|
||||
{
|
||||
name: 'Пользователи',
|
||||
value: stats.totalUsers,
|
||||
icon: Users,
|
||||
color: 'bg-gradient-to-r from-indigo-500 to-indigo-600',
|
||||
textColor: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50'
|
||||
}
|
||||
];
|
||||
] : [];
|
||||
|
||||
const latestNews = NEWS_DATA
|
||||
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||
.slice(0, 5);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Загрузка данных...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Панель управления</h1>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
|
||||
Панель управления
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Обзор системы управления новостями</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать новость
|
||||
@ -70,15 +148,15 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
{statsCards.map((stat) => (
|
||||
<div key={stat.name} className={`${stat.bgColor} rounded-xl shadow-sm p-6 border border-gray-100 hover:shadow-md transition-shadow duration-200`}>
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${stat.color} bg-opacity-10`}>
|
||||
<stat.icon className={`h-6 w-6 ${stat.textColor}`} />
|
||||
<div className={`p-3 rounded-lg ${stat.color} shadow-sm`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-600">{stat.name}</p>
|
||||
<p className="text-sm text-gray-600 font-medium">{stat.name}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -87,13 +165,13 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Последние новости</h2>
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium hover:underline"
|
||||
>
|
||||
Показать все
|
||||
</Link>
|
||||
@ -101,41 +179,49 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{latestNews.map((news) => (
|
||||
<div key={news.id} className="px-6 py-4 hover:bg-gray-50">
|
||||
<div key={news.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate max-w-md">
|
||||
{news.title}
|
||||
</h3>
|
||||
{news.featured && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Рекомендуемое
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-yellow-400 to-yellow-500 text-white">
|
||||
★ Рекомендуемое
|
||||
</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
news.published !== false
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
news.published
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{news.published !== false ? 'Опубликовано' : 'Черновик'}
|
||||
{news.published ? '✓ Опубликовано' : '📝 Черновик'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date(news.publishedAt).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
📅 {new Date(news.publishedAt).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
👁 {news.views} просмотров
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
❤️ {news.likes} лайков
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href={`/admin/news/${news.id}/edit`}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md transition-colors"
|
||||
>
|
||||
Редактировать
|
||||
</Link>
|
||||
<Link
|
||||
href={`/news/${news.slug}`}
|
||||
target="_blank"
|
||||
className="text-gray-600 hover:text-gray-800 text-sm"
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-md transition-colors"
|
||||
>
|
||||
Просмотр
|
||||
</Link>
|
||||
@ -143,47 +229,108 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{latestNews.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>Новости не найдены</p>
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium hover:underline"
|
||||
>
|
||||
Создать первую новость
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Быстрые действия</h3>
|
||||
{/* Enhanced Quick Actions & Analytics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Быстрые действия
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="flex items-center p-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center p-3 rounded-lg hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-blue-600 mr-3" />
|
||||
<span className="text-sm font-medium text-gray-900">Создать новость</span>
|
||||
<div className="p-2 bg-blue-100 rounded-lg group-hover:bg-blue-200 transition-colors">
|
||||
<Plus className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 ml-3">Создать новость</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="flex items-center p-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center p-3 rounded-lg hover:bg-green-50 transition-colors group"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-green-600 mr-3" />
|
||||
<span className="text-sm font-medium text-gray-900">Управление новостями</span>
|
||||
<div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
|
||||
<FileText className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 ml-3">Управление новостями</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="flex items-center p-3 rounded-lg hover:bg-purple-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-purple-100 rounded-lg group-hover:bg-purple-200 transition-colors">
|
||||
<Database className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 ml-3">Настройки</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Статистика по категориям</h3>
|
||||
<div className="space-y-3">
|
||||
{['company', 'promotions', 'other'].map((category) => {
|
||||
const count = NEWS_DATA.filter(news => news.category === category).length;
|
||||
const categoryName = category === 'company' ? 'Новости компании' :
|
||||
category === 'promotions' ? 'Акции' : 'Другое';
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ id: 'Общие новости', name: 'Общие новости', color: 'bg-blue-500' },
|
||||
{ id: 'Обследование канализации', name: 'Канализация', color: 'bg-green-500' },
|
||||
{ id: 'Тепловизионная экспертиза', name: 'Тепловизор', color: 'bg-purple-500' },
|
||||
{ id: 'Экспертиза при заливе', name: 'Залив', color: 'bg-red-500' },
|
||||
{ id: 'Строительная экспертиза', name: 'Строительство', color: 'bg-yellow-500' }
|
||||
].map((category) => {
|
||||
const count = latestNews.filter(news => news.category === category.id).length;
|
||||
return (
|
||||
<div key={category} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{categoryName}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{count}</span>
|
||||
<div key={category.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-3 h-3 rounded-full ${category.color} mr-3`}></div>
|
||||
<span className="text-sm text-gray-600">{category.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 bg-gray-100 px-2 py-1 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-sm p-6 border border-blue-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Система</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">База данных</span>
|
||||
<span className="text-sm font-medium text-green-600 flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
|
||||
Подключена
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">S3 хранилище</span>
|
||||
<span className="text-sm font-medium text-green-600 flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
|
||||
Активно
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Версия</span>
|
||||
<span className="text-sm font-medium text-gray-900">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
26
app/admin/test-s3/page.tsx
Normal file
26
app/admin/test-s3/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import S3Status from '../components/S3Status';
|
||||
|
||||
export default function TestS3Page() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Тест S3 подключения</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Статус S3:</h2>
|
||||
<S3Status />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-gray-100 rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Инструкции:</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||
<li>Откройте консоль разработчика (F12)</li>
|
||||
<li>Посмотрите на логи S3Status</li>
|
||||
<li>Если есть ошибка, нажмите кнопку "Повторить"</li>
|
||||
<li>Проверьте сетевые запросы в вкладке Network</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user