Версия 1.2.15 Добавлен фон под названием сайта и анонсы.
This commit is contained in:
parent
e95f1d52a2
commit
0251d5788f
2042
package-lock.json
generated
2042
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-react-typescript-starter",
|
"name": "vite-react-typescript-starter",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.14",
|
"version": "1.2.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "npx ts-node --project tsconfig.node.json --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
"seed": "npx ts-node --project tsconfig.node.json --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.525.0",
|
"@aws-sdk/client-s3": "^3.525.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
"@aws-sdk/s3-request-presigner": "^3.525.0",
|
||||||
"@headlessui/react": "^2.2.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
@ -80,6 +80,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^5.4.14"
|
"vite": "^5.4.14",
|
||||||
|
"vite-plugin-prerender": "^1.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 651 KiB After Width: | Height: | Size: 460 KiB |
BIN
public/images/panorama-hero-2.webp
Normal file
BIN
public/images/panorama-hero-2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
@ -8,6 +8,7 @@ import { AdminPage } from './pages/AdminPage';
|
|||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { UserManagementPage } from './pages/UserManagementPage';
|
import { UserManagementPage } from './pages/UserManagementPage';
|
||||||
import { AuthorManagementPage } from './pages/AuthorManagementPage';
|
import { AuthorManagementPage } from './pages/AuthorManagementPage';
|
||||||
|
import { AnnouncementManagementPage } from './pages/AnnouncementManagementPage';
|
||||||
import { SearchPage } from './pages/SearchPage';
|
import { SearchPage } from './pages/SearchPage';
|
||||||
import { BookmarksPage } from './pages/BookmarksPage';
|
import { BookmarksPage } from './pages/BookmarksPage';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
@ -78,6 +79,14 @@ function App() {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/announcements"
|
||||||
|
element={
|
||||||
|
<AuthGuard>
|
||||||
|
<AnnouncementManagementPage />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/import"
|
path="/admin/import"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -64,13 +64,13 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
|||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
<div className="flex items-center text-sm text-gray-500">
|
||||||
<Clock size={14} className="mr-1" />
|
|
||||||
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
|
|
||||||
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
|
{new Date(article.publishedAt).toLocaleDateString('ru-RU', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
})}
|
})} ·{' '}
|
||||||
|
<Clock size={14} className="mr-1" />
|
||||||
|
{article.readTime} <MinutesWord minutes={article.readTime} /> чтения
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Author, User } from "../types/auth.ts";
|
import { Author, User } from "../types/auth";
|
||||||
|
|
||||||
|
|
||||||
interface AuthorModalProps {
|
interface AuthorModalProps {
|
||||||
|
|||||||
@ -225,13 +225,18 @@ const AuthorSelectionModal: React.FC<AuthorSelectionModalProps> = ({
|
|||||||
<div id="other-authors-section" key={`other-${activeTab}`}>
|
<div id="other-authors-section" key={`other-${activeTab}`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-700">
|
<h3 className="text-lg font-semibold text-gray-700">
|
||||||
Все {roleLabels[activeTab].toLowerCase()}
|
Остальные {roleLabels[activeTab].toLowerCase()}
|
||||||
</h3>
|
</h3>
|
||||||
{showPagination && (
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
Страница {currentPage} из {totalPages}
|
Всего: {totalAuthors}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{showPagination && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Страница {currentPage} из {totalPages}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопки с первыми буквами */}
|
{/* Кнопки с первыми буквами */}
|
||||||
|
|||||||
56
src/components/Header/AnnouncementBanner.tsx
Normal file
56
src/components/Header/AnnouncementBanner.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementBanner() {
|
||||||
|
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/announcements/active');
|
||||||
|
if (response.data) {
|
||||||
|
setAnnouncement(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch announcement:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAnnouncement();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || !announcement || !isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-gradient-to-r from-blue-600 to-blue-700 text-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between py-3 sm:py-4">
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<p className="text-sm sm:text-base font-medium">
|
||||||
|
{announcement.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="ml-4 inline-flex text-white hover:bg-white/20 rounded-full p-1 transition-colors"
|
||||||
|
aria-label="Close announcement"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/Header/BannerHero.tsx
Normal file
21
src/components/Header/BannerHero.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export function BannerHero() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-16 sm:h-24 md:h-32 lg:h-42 xl:h-56 overflow-hidden bg-gray-900">
|
||||||
|
<img
|
||||||
|
src="/images/panorama-hero-2.webp"
|
||||||
|
alt="Культура Двух Столиц"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-black/70 via-black/60 to-black/50" />
|
||||||
|
|
||||||
|
<div className="relative h-full flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white uppercase tracking-tight">
|
||||||
|
Культура Двух Столиц
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -38,14 +38,6 @@ export function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-white shadow-sm">
|
<header className="sticky top-0 z-50 bg-white shadow-sm">
|
||||||
{/* Название блога над навигацией */}
|
|
||||||
<div className="bg-white border-b border-gray-100">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
|
||||||
<h1 className="text-center text-xl sm:text-2xl font-semibold text-gray-800 tracking-tight bg-gradient-to-b from-white to-gray-100 border-b border-gray-100 pb-2">
|
|
||||||
Культура Двух Столиц
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Main header row */}
|
{/* Main header row */}
|
||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
|
|||||||
@ -7,6 +7,6 @@ export const BackgroundImages: Record<number, string> = {
|
|||||||
6: '/images/pack/legend-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
6: '/images/pack/legend-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||||
7: '/images/pack/anniversary-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
7: '/images/pack/anniversary-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||||
8: '/images/pack/memory-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
8: '/images/pack/memory-bn.webp?auto=format&fit=crop&q=80&w=2070',
|
||||||
0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8bу?auto=format&fit=crop&q=80&w=2070'
|
0: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
|||||||
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
||||||
import { Article, Author, GalleryImage, ArticleData, AuthorRole, AuthorLink, ViewMode } from '../types';
|
import { Article, Author, GalleryImage, ArticleData, AuthorRole, AuthorLink, ViewMode } from '../types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { FileJson, Users, UserSquare2 } from "lucide-react";
|
import { FileJson, Users, UserSquare2, Bell } from "lucide-react";
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
@ -216,7 +216,7 @@ export function AdminPage() {
|
|||||||
|
|
||||||
{/* Блок кнопок навигации */}
|
{/* Блок кнопок навигации */}
|
||||||
{viewMode === 'list' && isAdmin && (
|
{viewMode === 'list' && isAdmin && (
|
||||||
<div className="flex gap-4 mb-8">
|
<div className="flex flex-wrap gap-4 mb-8">
|
||||||
<Link
|
<Link
|
||||||
to="/admin/users"
|
to="/admin/users"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
@ -231,6 +231,13 @@ export function AdminPage() {
|
|||||||
<UserSquare2 className="h-5 w-5 mr-2" />
|
<UserSquare2 className="h-5 w-5 mr-2" />
|
||||||
Авторы
|
Авторы
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/announcements"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5 mr-2" />
|
||||||
|
Анонсы
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/import"
|
to="/admin/import"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
|||||||
326
src/pages/AnnouncementManagementPage.tsx
Normal file
326
src/pages/AnnouncementManagementPage.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Plus, Pencil, Trash2, ArrowLeft, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'create' | 'edit';
|
||||||
|
|
||||||
|
export function AnnouncementManagementPage() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnnouncements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await axios.get('/api/announcements', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setAnnouncements(response.data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Сбой загрузки Анонсов');
|
||||||
|
console.error('Ошибка загрузки Анонсов:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setMessage('');
|
||||||
|
setEditingId(null);
|
||||||
|
setViewMode('create');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
const announcement = announcements.find(a => a.id === id);
|
||||||
|
if (announcement) {
|
||||||
|
setMessage(announcement.message);
|
||||||
|
setEditingId(id);
|
||||||
|
setViewMode('edit');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setViewMode('list');
|
||||||
|
setMessage('');
|
||||||
|
setEditingId(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
setError('Message cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
await axios.put(
|
||||||
|
`/api/announcements/${editingId}`,
|
||||||
|
{ message: message.trim() },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await axios.post(
|
||||||
|
'/api/announcements',
|
||||||
|
{ message: message.trim() },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchAnnouncements();
|
||||||
|
handleCancel();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to save announcement');
|
||||||
|
console.error('Error saving announcement:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
await axios.delete(
|
||||||
|
`/api/announcements/${id}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchAnnouncements();
|
||||||
|
setShowDeleteModal(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to delete announcement');
|
||||||
|
console.error('Error deleting announcement:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const announcement = announcements.find(a => a.id === id);
|
||||||
|
if (!announcement) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
await axios.put(
|
||||||
|
`/api/announcements/${id}`,
|
||||||
|
{ isActive: !announcement.isActive },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchAnnouncements();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to update announcement');
|
||||||
|
console.error('Error updating announcement:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{/* Form View */}
|
||||||
|
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="mr-4 p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{viewMode === 'edit' ? 'Редактирование Анонса' : 'Создание Анонса'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
|
||||||
|
Сообщение Анонса
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows={6}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Ведите сообщение ананса (например, 'Нам 10 лет!')"
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Это сообщение будет отображено в баннере в верхней части главной страницы.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{viewMode === 'edit' ? 'Изменить' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List View */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">
|
||||||
|
Анонсы
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Новый
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<p className="text-gray-500">Загрузка анонсов...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-6 py-12">
|
||||||
|
<div className="bg-red-50 text-red-700 p-4 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<p className="text-gray-500">Анонсов еще нет.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать первый Анонс
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<li
|
||||||
|
key={announcement.id}
|
||||||
|
className={`px-6 py-4 ${!announcement.isActive ? 'bg-gray-50' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 break-words">
|
||||||
|
{announcement.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Created: {new Date(announcement.createdAt).toLocaleDateString()} at {new Date(announcement.createdAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
{announcement.isActive && (
|
||||||
|
<span className="inline-block mt-2 px-2 py-1 text-xs font-medium text-white bg-green-600 rounded">
|
||||||
|
Currently Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(announcement.id)}
|
||||||
|
className={`p-2 rounded-full hover:bg-gray-100 transition-colors ${
|
||||||
|
announcement.isActive ? 'text-green-600' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
title={announcement.isActive ? 'Deactivate' : 'Activate'}
|
||||||
|
>
|
||||||
|
{announcement.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(announcement.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(announcement.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Удаление Анонса
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Вы уверены, что хотите удалить этот Анонс? Это действие невозможно отменить.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(null)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(showDeleteModal)}
|
||||||
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,9 @@ import { BackgroundImages } from '../hooks/useBackgroundImage';
|
|||||||
import { useScrollStore } from '../stores/scrollStore';
|
import { useScrollStore } from '../stores/scrollStore';
|
||||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||||
import { SEO } from '../components/SEO';
|
import { SEO } from '../components/SEO';
|
||||||
|
import { BannerHero } from "../components/Header/BannerHero";
|
||||||
|
import { AnnouncementBanner } from '../components/Header/AnnouncementBanner';
|
||||||
|
|
||||||
//import { MasterBio } from "../components/MasterBio";
|
//import { MasterBio } from "../components/MasterBio";
|
||||||
|
|
||||||
|
|
||||||
@ -45,19 +48,21 @@ export function HomePage() {
|
|||||||
|
|
||||||
const getSEODescription = () => {
|
const getSEODescription = () => {
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
return `Explore the latest ${CategoryTitles[Number(categoryId)].toLowerCase()} stories, events, and cultural highlights from around the globe.`;
|
return `Ознакомьтесь с последними историями, событиями ${CategoryTitles[Number(categoryId)].toLowerCase()} Москвы и Петербурга.`;
|
||||||
}
|
}
|
||||||
return 'Discover the latest in art, music, theater, and cultural events from around the globe. Your premier destination for arts and culture coverage.';
|
return 'Узнайте о последних достижениях искусства, музыки, театра и культурных мероприятиях Москвы и Петербурга. Это лучшее место для знакомства с искусством и культурой.';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/gpt_main-bg.webp')` }}>
|
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.webp')` }}>
|
||||||
<SEO
|
<SEO
|
||||||
title={categoryId ? `${CategoryTitles[Number(categoryId)]} Статьи - Культура двух Столиц` : undefined}
|
title={categoryId ? `${CategoryTitles[Number(categoryId)]} Статьи - Культура двух Столиц` : undefined}
|
||||||
description={getSEODescription()}
|
description={getSEODescription()}
|
||||||
keywords={categoryId ? [CategoryTitles[Number(categoryId)].toLowerCase(), 'культура', 'искусство', 'события'] : undefined}
|
keywords={categoryId ? [CategoryTitles[Number(categoryId)].toLowerCase(), 'культура', 'искусство', 'события'] : undefined}
|
||||||
image={backgroundImage}
|
image={backgroundImage}
|
||||||
/>
|
/>
|
||||||
|
<AnnouncementBanner />
|
||||||
|
<BannerHero />
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
|||||||
@ -2,7 +2,19 @@
|
|||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
// Ключевые кадры для анимации
|
||||||
|
keyframes: {
|
||||||
|
fadeInUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Название анимации для использования в классах
|
||||||
|
animation: {
|
||||||
|
'fade-in-up': 'fadeInUp 1s ease-out',
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user