Версия 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",
"private": true,
"version": "1.2.14",
"version": "1.2.15",
"type": "module",
"scripts": {
"dev": "vite",
@ -17,7 +17,7 @@
"prisma": {
"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/s3-request-presigner": "^3.525.0",
"@headlessui/react": "^2.2.2",
@ -80,6 +80,7 @@
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"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 { UserManagementPage } from './pages/UserManagementPage';
import { AuthorManagementPage } from './pages/AuthorManagementPage';
import { AnnouncementManagementPage } from './pages/AnnouncementManagementPage';
import { SearchPage } from './pages/SearchPage';
import { BookmarksPage } from './pages/BookmarksPage';
import { Footer } from './components/Footer';
@ -78,6 +79,14 @@ function App() {
</AuthGuard>
}
/>
<Route
path="/admin/announcements"
element={
<AuthGuard>
<AnnouncementManagementPage />
</AuthGuard>
}
/>
<Route
path="/admin/import"
element={

View File

@ -64,13 +64,13 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
))}
</p>
<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', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
})} ·{' '}
<Clock size={14} className="mr-1" />
{article.readTime} <MinutesWord minutes={article.readTime} /> чтения
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Author, User } from "../types/auth.ts";
import { Author, User } from "../types/auth";
interface AuthorModalProps {

View File

@ -225,13 +225,18 @@ const AuthorSelectionModal: React.FC<AuthorSelectionModalProps> = ({
<div id="other-authors-section" key={`other-${activeTab}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-700">
Все {roleLabels[activeTab].toLowerCase()}
Остальные {roleLabels[activeTab].toLowerCase()}
</h3>
{showPagination && (
<span className="text-sm text-gray-500">
Страница {currentPage} из {totalPages}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500">
Всего: {totalAuthors}
</span>
)}
{showPagination && (
<span className="text-sm text-gray-500">
Страница {currentPage} из {totalPages}
</span>
)}
</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 city = event.target.value;
const params = new URLSearchParams(location.search);
if (city) {
params.set('city', city);
} else {
params.delete('city');
}
window.location.href = `/?${params.toString()}`;
};
return (
<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">
{/* Main header row */}
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<button
<button
className="p-2 rounded-md text-gray-500 xl:hidden hover:bg-gray-100"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
@ -69,10 +61,10 @@ export function Header() {
<div className="h-6 w-px bg-gray-200" />
<Navigation
<Navigation
categories={CategoryIds}
currentCategory={currentCategory}
currentCity={currentCity}
currentCategory={currentCategory}
currentCity={currentCity}
/>
</nav>
@ -114,7 +106,7 @@ export function Header() {
</div>
</div>
<MobileMenu
<MobileMenu
isOpen={isMobileMenuOpen}
categories={CategoryIds}
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',
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',
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 { Article, Author, GalleryImage, ArticleData, AuthorRole, AuthorLink, ViewMode } from '../types';
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 { useAuthStore } from "../stores/authStore";
@ -216,7 +216,7 @@ export function AdminPage() {
{/* Блок кнопок навигации */}
{viewMode === 'list' && isAdmin && (
<div className="flex gap-4 mb-8">
<div className="flex flex-wrap gap-4 mb-8">
<Link
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"
@ -231,6 +231,13 @@ export function AdminPage() {
<UserSquare2 className="h-5 w-5 mr-2" />
Авторы
</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
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"

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 { CategoryDescription, CategoryText, CategoryTitles } from '../types';
import { SEO } from '../components/SEO';
import { BannerHero } from "../components/Header/BannerHero";
import { AnnouncementBanner } from '../components/Header/AnnouncementBanner';
//import { MasterBio } from "../components/MasterBio";
@ -45,19 +48,21 @@ export function HomePage() {
const getSEODescription = () => {
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 (
<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
title={categoryId ? `${CategoryTitles[Number(categoryId)]} Статьи - Культура двух Столиц` : undefined}
description={getSEODescription()}
keywords={categoryId ? [CategoryTitles[Number(categoryId)].toLowerCase(), 'культура', 'искусство', 'события'] : undefined}
image={backgroundImage}
/>
<AnnouncementBanner />
<BannerHero />
<Header />
<main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">

View File

@ -2,7 +2,19 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
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: [],
};
};