feat: add Exhibition and Fight applications pages with authentication and approval functionality
This commit is contained in:
@@ -4,6 +4,16 @@ const nextConfig: NextConfig = {
|
|||||||
/* config options here */
|
/* config options here */
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "base.mossport.info",
|
||||||
|
port: "",
|
||||||
|
pathname: "/api/files/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Image from "next/image";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { fluxgore, gothampro } from "@/utils/fonts";
|
||||||
|
|
||||||
|
const pb = new PocketBase("https://base.mossport.info");
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
carBrand: string;
|
||||||
|
carModel: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Application {
|
||||||
|
id: string;
|
||||||
|
data: FormData;
|
||||||
|
images: string[];
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
approved?: boolean;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExhibitionApplicationsPage() {
|
||||||
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [credentials, setCredentials] = useState({ email: "", password: "" });
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем, авторизован ли пользователь
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
fetchApplications();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.admins.authWithPassword(credentials.email, credentials.password);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setLoading(true);
|
||||||
|
await fetchApplications();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth error:", error);
|
||||||
|
setAuthError("Неверный email или пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
pb.authStore.clear();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setApplications([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchApplications = async () => {
|
||||||
|
try {
|
||||||
|
const records = await pb.collection("forms").getFullList({
|
||||||
|
filter: 'type = "exhibition" && approved != true',
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedApplications = records.map((record: any) => ({
|
||||||
|
id: record.id,
|
||||||
|
data:
|
||||||
|
typeof record.data === "string"
|
||||||
|
? JSON.parse(record.data)
|
||||||
|
: record.data,
|
||||||
|
images: record.images || [],
|
||||||
|
type: record.type,
|
||||||
|
status: record.status || "pending",
|
||||||
|
approved: record.approved || false,
|
||||||
|
created: record.created,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setApplications(formattedApplications);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching applications:", error);
|
||||||
|
|
||||||
|
// Detailed error logging
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"response" in error &&
|
||||||
|
typeof (error as any).response === "object"
|
||||||
|
) {
|
||||||
|
console.error("Response status:", (error as any).response.status);
|
||||||
|
console.error("Response data:", (error as any).response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "object" && error !== null && "data" in error) {
|
||||||
|
console.error("Error data:", (error as any).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message.includes("403")) {
|
||||||
|
setAuthError("Недостаточно прав доступа");
|
||||||
|
handleLogout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
setProcessingId(id);
|
||||||
|
try {
|
||||||
|
await pb.collection("forms").update(id, {
|
||||||
|
status: "approved",
|
||||||
|
approved: true
|
||||||
|
});
|
||||||
|
setApplications((prev) => prev.filter((app) => app.id !== id));
|
||||||
|
alert("Заявка одобрена!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error approving application:", error);
|
||||||
|
alert("Ошибка при одобрении заявки");
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
setProcessingId(id);
|
||||||
|
try {
|
||||||
|
await pb.collection("forms").update(id, {
|
||||||
|
status: "rejected",
|
||||||
|
approved: false
|
||||||
|
});
|
||||||
|
setApplications((prev) => prev.filter((app) => app.id !== id));
|
||||||
|
alert("Заявка отклонена!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rejecting application:", error);
|
||||||
|
alert("Ошибка при отклонении заявки");
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageUrl = (record: Application, filename: string) => {
|
||||||
|
const url = `${pb.baseUrl}/api/files/forms/${record.id}/${filename}`;
|
||||||
|
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
return `${url}${separator}token=${pb.authStore.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форма авторизации
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<div className="max-w-md w-full mx-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-8 space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2
|
||||||
|
className={`${fluxgore.className} text-3xl text-gray-900 mb-2`}
|
||||||
|
>
|
||||||
|
Панель администратора
|
||||||
|
</h2>
|
||||||
|
<p className={`${gothampro.className} text-gray-600`}>
|
||||||
|
Войдите для доступа к заявкам на выставку
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="space-y-6" onSubmit={handleLogin}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className={`${gothampro.className} block text-sm font-medium text-gray-700 mb-2`}
|
||||||
|
>
|
||||||
|
Email администратора
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className={`${gothampro.className} w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200`}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
value={credentials.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className={`${gothampro.className} block text-sm font-medium text-gray-700 mb-2`}
|
||||||
|
>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className={`${gothampro.className} w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<div
|
||||||
|
className={`${gothampro.className} text-red-600 text-sm text-center bg-red-50 p-3 rounded-lg`}
|
||||||
|
>
|
||||||
|
{authError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${fluxgore.className} w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<div className={`${gothampro.className} text-xl text-gray-600`}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Head>
|
||||||
|
<title>Заявки на выставку автомобилей - Модерация</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow-sm border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1
|
||||||
|
className={`${fluxgore.className} text-2xl md:text-4xl text-[#060606] uppercase`}
|
||||||
|
>
|
||||||
|
Заявки на выставку автомобилей
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`${fluxgore.className} bg-gray-600 hover:bg-gray-700 text-white px-6 py-3 text-sm font-medium uppercase tracking-wide transition-colors rounded-lg`}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
{applications.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-16 h-16 bg-gray-200 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className={`${fluxgore.className} text-xl text-gray-900 mb-2`}>
|
||||||
|
Нет заявок
|
||||||
|
</h3>
|
||||||
|
<p className={`${gothampro.className} text-gray-500`}>
|
||||||
|
Пока нет заявок на выставку для модерации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{applications.map((application) => (
|
||||||
|
<div
|
||||||
|
key={application.id}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Личная информация */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] border-b border-gray-200 pb-2`}
|
||||||
|
>
|
||||||
|
Информация об участнике
|
||||||
|
</h3>
|
||||||
|
<div className={`${gothampro.className} space-y-3`}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Имя:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Телефон:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Email:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация об автомобиле для выставки */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] border-b border-gray-200 pb-2`}
|
||||||
|
>
|
||||||
|
Автомобиль для выставки
|
||||||
|
</h3>
|
||||||
|
<div className={`${gothampro.className} space-y-3`}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Марка:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.carBrand}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Модель:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.carModel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{application.data.description && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-gray-700 mb-1">
|
||||||
|
Описание:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900 bg-gray-50 p-3 rounded-lg">
|
||||||
|
{application.data.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фотографии */}
|
||||||
|
{application.images.length > 0 && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] mb-6`}
|
||||||
|
>
|
||||||
|
Фотографии для выставки ({application.images.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{application.images.map((image, index) => {
|
||||||
|
const imageUrl = getImageUrl(application, image);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative cursor-pointer group aspect-square overflow-hidden rounded-lg bg-gray-100"
|
||||||
|
onClick={() => setSelectedImage(imageUrl)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`Фото для выставки ${index + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
|
||||||
|
<div className="bg-white bg-opacity-90 text-gray-800 px-3 py-1 rounded-full text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
Увеличить
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дата подачи заявки */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div
|
||||||
|
className={`${gothampro.className} text-sm text-gray-500`}
|
||||||
|
>
|
||||||
|
Заявка подана:{" "}
|
||||||
|
{new Date(application.created).toLocaleString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
<div className="bg-gray-50 px-8 py-4 flex gap-4 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(application.id)}
|
||||||
|
disabled={processingId === application.id}
|
||||||
|
className={`${fluxgore.className} bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white px-6 py-2 text-sm font-medium uppercase tracking-wide rounded-lg transition-colors duration-200`}
|
||||||
|
>
|
||||||
|
{processingId === application.id
|
||||||
|
? "Обработка..."
|
||||||
|
: "Отклонить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(application.id)}
|
||||||
|
disabled={processingId === application.id}
|
||||||
|
className={`${fluxgore.className} bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2 text-sm font-medium uppercase tracking-wide rounded-lg transition-colors duration-200`}
|
||||||
|
>
|
||||||
|
{processingId === application.id
|
||||||
|
? "Обработка..."
|
||||||
|
: "Принять"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно для увеличенного изображения */}
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4 backdrop-blur-sm"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative max-w-5xl max-h-full w-full"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative bg-white rounded-lg overflow-hidden shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Увеличенное фото"
|
||||||
|
width={1200}
|
||||||
|
height={900}
|
||||||
|
className="w-full h-auto max-h-[80vh] object-contain"
|
||||||
|
onError={() => {
|
||||||
|
console.error("Modal image failed to load:", selectedImage);
|
||||||
|
setSelectedImage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
className="absolute top-4 right-4 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-10 h-10 flex items-center justify-center transition-all duration-200 z-10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<p
|
||||||
|
className={`${gothampro.className} text-white text-sm opacity-75`}
|
||||||
|
>
|
||||||
|
Нажмите вне изображения или на × чтобы закрыть
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExhibitionApplicationsPage;
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Image from "next/image";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { fluxgore, gothampro } from "@/utils/fonts";
|
||||||
|
|
||||||
|
const pb = new PocketBase("https://base.mossport.info");
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
lastName: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string;
|
||||||
|
birthDate: string;
|
||||||
|
citizenship: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
carBrand: string;
|
||||||
|
carModel: string;
|
||||||
|
engine: string;
|
||||||
|
power: string;
|
||||||
|
additionalInfo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Application {
|
||||||
|
id: string;
|
||||||
|
data: FormData;
|
||||||
|
images: string[];
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
approved?: boolean; // Add this new field
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FightApplicationsPage() {
|
||||||
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [credentials, setCredentials] = useState({ email: "", password: "" });
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем, авторизован ли пользователь
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
fetchApplications();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.admins.authWithPassword(credentials.email, credentials.password);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setLoading(true);
|
||||||
|
await fetchApplications();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth error:", error);
|
||||||
|
setAuthError("Неверный email или пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
pb.authStore.clear();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setApplications([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchApplications = async () => {
|
||||||
|
try {
|
||||||
|
const records = await pb.collection("forms").getFullList({
|
||||||
|
filter: 'type = "fight" && approved != true', // Updated filter to use boolean field
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedApplications = records.map((record: any) => ({
|
||||||
|
id: record.id,
|
||||||
|
data:
|
||||||
|
typeof record.data === "string"
|
||||||
|
? JSON.parse(record.data)
|
||||||
|
: record.data,
|
||||||
|
images: record.images || [],
|
||||||
|
type: record.type,
|
||||||
|
status: record.status || "pending",
|
||||||
|
approved: record.approved || false, // Include the approved field
|
||||||
|
created: record.created,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setApplications(formattedApplications);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching applications:", error);
|
||||||
|
|
||||||
|
// Detailed error logging
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"response" in error &&
|
||||||
|
typeof (error as any).response === "object"
|
||||||
|
) {
|
||||||
|
console.error("Response status:", (error as any).response.status);
|
||||||
|
console.error("Response data:", (error as any).response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "object" && error !== null && "data" in error) {
|
||||||
|
console.error("Error data:", (error as any).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more detailed error logging
|
||||||
|
if (typeof error === "object" && error !== null && "data" in error) {
|
||||||
|
console.error("Error details:", (error as any).data);
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.includes("403")) {
|
||||||
|
setAuthError("Недостаточно прав доступа");
|
||||||
|
handleLogout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
setProcessingId(id);
|
||||||
|
try {
|
||||||
|
await pb.collection("forms").update(id, {
|
||||||
|
status: "approved",
|
||||||
|
approved: true // Set the boolean field
|
||||||
|
});
|
||||||
|
setApplications((prev) => prev.filter((app) => app.id !== id));
|
||||||
|
alert("Заявка одобрена!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error approving application:", error);
|
||||||
|
alert("Ошибка при одобрении заявки");
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
setProcessingId(id);
|
||||||
|
try {
|
||||||
|
await pb.collection("forms").update(id, {
|
||||||
|
status: "rejected",
|
||||||
|
approved: false // Explicitly set to false
|
||||||
|
});
|
||||||
|
setApplications((prev) => prev.filter((app) => app.id !== id));
|
||||||
|
alert("Заявка отклонена!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rejecting application:", error);
|
||||||
|
alert("Ошибка при отклонении заявки");
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageUrl = (record: Application, filename: string) => {
|
||||||
|
// Manual URL construction with proper token handling
|
||||||
|
const url = `${pb.baseUrl}/api/files/forms/${record.id}/${filename}`;
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
return `${url}${separator}token=${pb.authStore.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форма авторизации
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<div className="max-w-md w-full mx-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-8 space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2
|
||||||
|
className={`${fluxgore.className} text-3xl text-gray-900 mb-2`}
|
||||||
|
>
|
||||||
|
Панель администратора
|
||||||
|
</h2>
|
||||||
|
<p className={`${gothampro.className} text-gray-600`}>
|
||||||
|
Войдите для доступа к заявкам
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="space-y-6" onSubmit={handleLogin}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className={`${gothampro.className} block text-sm font-medium text-gray-700 mb-2`}
|
||||||
|
>
|
||||||
|
Email администратора
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className={`${gothampro.className} w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200`}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
value={credentials.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className={`${gothampro.className} block text-sm font-medium text-gray-700 mb-2`}
|
||||||
|
>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className={`${gothampro.className} w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<div
|
||||||
|
className={`${gothampro.className} text-red-600 text-sm text-center bg-red-50 p-3 rounded-lg`}
|
||||||
|
>
|
||||||
|
{authError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${fluxgore.className} w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<div className={`${gothampro.className} text-xl text-gray-600`}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Head>
|
||||||
|
<title>Заявки на Битву за Москву - Модерация</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow-sm border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1
|
||||||
|
className={`${fluxgore.className} text-2xl md:text-4xl text-[#060606] uppercase`}
|
||||||
|
>
|
||||||
|
Заявки на Битву за Москву
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`${fluxgore.className} bg-gray-600 hover:bg-gray-700 text-white px-6 py-3 text-sm font-medium uppercase tracking-wide transition-colors rounded-lg`}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
{applications.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-16 h-16 bg-gray-200 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className={`${fluxgore.className} text-xl text-gray-900 mb-2`}>
|
||||||
|
Нет заявок
|
||||||
|
</h3>
|
||||||
|
<p className={`${gothampro.className} text-gray-500`}>
|
||||||
|
Пока нет заявок для модерации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{applications.map((application) => (
|
||||||
|
<div
|
||||||
|
key={application.id}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Личная информация */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] border-b border-gray-200 pb-2`}
|
||||||
|
>
|
||||||
|
Личная информация
|
||||||
|
</h3>
|
||||||
|
<div className={`${gothampro.className} space-y-3`}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
ФИО:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.lastName}{" "}
|
||||||
|
{application.data.firstName}{" "}
|
||||||
|
{application.data.middleName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Дата рождения:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.birthDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Гражданство:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.citizenship}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Телефон:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-32">
|
||||||
|
Email:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация об автомобиле */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] border-b border-gray-200 pb-2`}
|
||||||
|
>
|
||||||
|
Автомобиль
|
||||||
|
</h3>
|
||||||
|
<div className={`${gothampro.className} space-y-3`}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Марка:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.carBrand}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Модель:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.carModel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Двигатель:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.engine}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||||
|
<span className="font-semibold text-gray-700 w-24">
|
||||||
|
Мощность:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{application.data.power}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{application.data.additionalInfo && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-gray-700 mb-1">
|
||||||
|
Доп. информация:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900 bg-gray-50 p-3 rounded-lg">
|
||||||
|
{application.data.additionalInfo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фотографии */}
|
||||||
|
{application.images.length > 0 && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<h3
|
||||||
|
className={`${fluxgore.className} text-xl text-[#1068B0] mb-6`}
|
||||||
|
>
|
||||||
|
Фотографии автомобиля ({application.images.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{application.images.map((image, index) => {
|
||||||
|
const imageUrl = getImageUrl(application, image);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative cursor-pointer group aspect-square overflow-hidden rounded-lg bg-gray-100"
|
||||||
|
onClick={() => setSelectedImage(imageUrl)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`Фото автомобиля ${index + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
|
||||||
|
<div className="bg-white bg-opacity-90 text-gray-800 px-3 py-1 rounded-full text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
Увеличить
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дата подачи заявки */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div
|
||||||
|
className={`${gothampro.className} text-sm text-gray-500`}
|
||||||
|
>
|
||||||
|
Заявка подана:{" "}
|
||||||
|
{new Date(application.created).toLocaleString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
<div className="bg-gray-50 px-8 py-4 flex gap-4 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(application.id)}
|
||||||
|
disabled={processingId === application.id}
|
||||||
|
className={`${fluxgore.className} bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white px-6 py-2 text-sm font-medium uppercase tracking-wide rounded-lg transition-colors duration-200`}
|
||||||
|
>
|
||||||
|
{processingId === application.id
|
||||||
|
? "Обработка..."
|
||||||
|
: "Отклонить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(application.id)}
|
||||||
|
disabled={processingId === application.id}
|
||||||
|
className={`${fluxgore.className} bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2 text-sm font-medium uppercase tracking-wide rounded-lg transition-colors duration-200`}
|
||||||
|
>
|
||||||
|
{processingId === application.id
|
||||||
|
? "Обработка..."
|
||||||
|
: "Принять"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Улучшенное модальное окно для увеличенного изображения */}
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4 backdrop-blur-sm"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative max-w-5xl max-h-full w-full"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative bg-white rounded-lg overflow-hidden shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Увеличенное фото"
|
||||||
|
width={1200}
|
||||||
|
height={900}
|
||||||
|
className="w-full h-auto max-h-[80vh] object-contain"
|
||||||
|
onError={() => {
|
||||||
|
console.error("Modal image failed to load:", selectedImage);
|
||||||
|
setSelectedImage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
className="absolute top-4 right-4 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-10 h-10 flex items-center justify-center transition-all duration-200 z-10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<p
|
||||||
|
className={`${gothampro.className} text-white text-sm opacity-75`}
|
||||||
|
>
|
||||||
|
Нажмите вне изображения или на × чтобы закрыть
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FightApplicationsPage;
|
||||||
Reference in New Issue
Block a user