Версия 1.2.15 Добавлен фон под названием сайта и анонсы.

This commit is contained in:
anibilag 2025-11-24 22:53:56 +03:00
parent e95f1d52a2
commit 0251d5788f
16 changed files with 2487 additions and 61 deletions

2042
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

@ -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={

View File

@ -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>

View File

@ -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 {

View File

@ -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>
{/* Кнопки с первыми буквами */} {/* Кнопки с первыми буквами */}

View 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>
);
}

View 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>
);
}

View File

@ -26,31 +26,23 @@ export function Header() {
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const city = event.target.value; const city = event.target.value;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (city) { if (city) {
params.set('city', city); params.set('city', city);
} else { } else {
params.delete('city'); params.delete('city');
} }
window.location.href = `/?${params.toString()}`; window.location.href = `/?${params.toString()}`;
}; };
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">
<div className="flex items-center"> <div className="flex items-center">
<button <button
className="p-2 rounded-md text-gray-500 xl:hidden hover:bg-gray-100" className="p-2 rounded-md text-gray-500 xl:hidden hover:bg-gray-100"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
> >
@ -69,10 +61,10 @@ export function Header() {
<div className="h-6 w-px bg-gray-200" /> <div className="h-6 w-px bg-gray-200" />
<Navigation <Navigation
categories={CategoryIds} categories={CategoryIds}
currentCategory={currentCategory} currentCategory={currentCategory}
currentCity={currentCity} currentCity={currentCity}
/> />
</nav> </nav>
@ -114,7 +106,7 @@ export function Header() {
</div> </div>
</div> </div>
<MobileMenu <MobileMenu
isOpen={isMobileMenuOpen} isOpen={isMobileMenuOpen}
categories={CategoryIds} categories={CategoryIds}
cities={CityIds} cities={CityIds}

View File

@ -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: ''
}; };

View File

@ -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"

View 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>
);
}

View File

@ -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">

View File

@ -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: [],
}; };