Переход от монолитной Header к набору компонентов. Работает поиск статей по дате. Запоминание позиции скролинга при переходе к статье.
This commit is contained in:
parent
933d82e3ac
commit
9ca069c49b
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20 AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
# Переход на stage со статикой
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
@ -2,7 +2,7 @@ import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||||
import MinutesWord from './Words/MinutesWord';
|
import MinutesWord from './Words/MinutesWord';
|
||||||
|
import { useScrollStore } from '../stores/scrollStore';
|
||||||
|
|
||||||
interface ArticleCardProps {
|
interface ArticleCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
@ -11,11 +11,19 @@ interface ArticleCardProps {
|
|||||||
|
|
||||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const setHomeScrollPosition = useScrollStore(state => state.setHomeScrollPosition);
|
||||||
|
|
||||||
const writerAuthors = article.authors
|
const writerAuthors = article.authors
|
||||||
.filter(a => a.role === 'WRITER')
|
.filter(a => a.role === 'WRITER')
|
||||||
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||||
|
|
||||||
|
const handleArticleClick = () => {
|
||||||
|
// Сохранить текущее положение скролинга при переходе к статье
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
setHomeScrollPosition(window.scrollY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||||
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||||
@ -76,6 +84,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
|||||||
<Link
|
<Link
|
||||||
to={`/article/${article.id}`}
|
to={`/article/${article.id}`}
|
||||||
state={{ from: location.pathname + location.search }}
|
state={{ from: location.pathname + location.search }}
|
||||||
|
onClick={handleArticleClick}
|
||||||
className="text-blue-600 font-medium hover:text-blue-800"
|
className="text-blue-600 font-medium hover:text-blue-800"
|
||||||
>
|
>
|
||||||
Читать →
|
Читать →
|
||||||
|
@ -4,11 +4,12 @@ import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
|
|||||||
import { ImageUploader } from './ImageUpload/ImageUploader';
|
import { ImageUploader } from './ImageUpload/ImageUploader';
|
||||||
import { GalleryManager } from './GalleryManager';
|
import { GalleryManager } from './GalleryManager';
|
||||||
import { useGallery } from '../hooks/useGallery';
|
import { useGallery } from '../hooks/useGallery';
|
||||||
import { ArticleData, Author, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
import { ArticleData, Author, AuthorLink, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import RolesWord from "./Words/RolesWord";
|
import RolesWord from "./Words/RolesWord";
|
||||||
import { Trash2, UserPlus } from "lucide-react";
|
import { Trash2, UserPlus } from "lucide-react";
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
|
|
||||||
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
||||||
@ -56,6 +57,7 @@ export function ArticleForm({
|
|||||||
}: ArticleFormProps) {
|
}: ArticleFormProps) {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const isAdmin = user?.permissions.isAdmin || false;
|
const isAdmin = user?.permissions.isAdmin || false;
|
||||||
|
const showGallery = false;
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [excerpt, setExcerpt] = useState('');
|
const [excerpt, setExcerpt] = useState('');
|
||||||
@ -75,14 +77,15 @@ export function ArticleForm({
|
|||||||
const [newRole, setNewRole] = useState('');
|
const [newRole, setNewRole] = useState('');
|
||||||
const [newAuthorId, setNewAuthorId] = useState('');
|
const [newAuthorId, setNewAuthorId] = useState('');
|
||||||
const [showAddAuthorModal, setShowAddAuthorModal] = useState(false);
|
const [showAddAuthorModal, setShowAddAuthorModal] = useState(false);
|
||||||
|
const [formReady, setFormReady] = useState(false);
|
||||||
|
|
||||||
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
|
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
|
||||||
|
|
||||||
const [selectedAuthors, setSelectedAuthors] = useState<Array<{
|
const [selectedAuthors, setSelectedAuthors] = useState<AuthorLink[]>([]);
|
||||||
authorId: string;
|
|
||||||
role: AuthorRole;
|
useEffect(() => {
|
||||||
author: Author;
|
if (initialFormState) setFormReady(true);
|
||||||
}>>([]);
|
}, [initialFormState]);
|
||||||
|
|
||||||
// Добавляем обработку ошибок
|
// Добавляем обработку ошибок
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -135,12 +138,27 @@ export function ArticleForm({
|
|||||||
}, [initialFormState]);
|
}, [initialFormState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialFormState) return;
|
if (!initialFormState || !formReady) return;
|
||||||
|
|
||||||
|
const currentState: FormState = {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
categoryId,
|
||||||
|
cityId,
|
||||||
|
coverImage,
|
||||||
|
readTime,
|
||||||
|
content,
|
||||||
|
authors: selectedAuthors,
|
||||||
|
galleryImages: showGallery ? displayedImages : initialFormState.galleryImages,
|
||||||
|
};
|
||||||
|
|
||||||
const currentState: FormState = { title, excerpt, categoryId, cityId, coverImage, readTime, content, authors: selectedAuthors, galleryImages: displayedImages };
|
|
||||||
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
||||||
|
|
||||||
const hasFormChanges = Object.keys(initialFormState).some(key => {
|
const hasFormChanges = Object.keys(initialFormState).some(key => {
|
||||||
|
if (!formReady) return false;
|
||||||
|
|
||||||
if (key === 'galleryImages') {
|
if (key === 'galleryImages') {
|
||||||
|
if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
|
||||||
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||||
if (isInitialLoad && isDifferent) return false;
|
if (isInitialLoad && isDifferent) return false;
|
||||||
return isDifferent;
|
return isDifferent;
|
||||||
@ -150,7 +168,8 @@ export function ArticleForm({
|
|||||||
}
|
}
|
||||||
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
|
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
|
||||||
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
|
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
|
||||||
return currentValue !== initialValue;
|
|
||||||
|
return !isEqual(currentValue, initialValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
|
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
|
||||||
@ -158,7 +177,7 @@ export function ArticleForm({
|
|||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, selectedAuthors, displayedImages, initialFormState, isInitialLoad]);
|
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, selectedAuthors, displayedImages, initialFormState, isInitialLoad, formReady, showGallery]);
|
||||||
|
|
||||||
const filteredAuthors = authors.filter(
|
const filteredAuthors = authors.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
@ -195,10 +214,7 @@ export function ArticleForm({
|
|||||||
content: content || '',
|
content: content || '',
|
||||||
importId: 0,
|
importId: 0,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
authors: selectedAuthors.map(a => ({
|
authors: selectedAuthors,
|
||||||
author: a.author,
|
|
||||||
role: a.role,
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -391,51 +407,55 @@ export function ArticleForm({
|
|||||||
articleId={articleId}
|
articleId={articleId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
{showGallery && (
|
||||||
<span className="italic font-bold">Галерея</span>
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
{editingId ? (
|
<span className="italic font-bold">Галерея</span>
|
||||||
<>
|
</label>
|
||||||
<ImageUploader
|
{editingId ? (
|
||||||
onUploadComplete={(imageUrl) => {
|
<>
|
||||||
setFormNewImageUrl(imageUrl);
|
<ImageUploader
|
||||||
}}
|
onUploadComplete={(imageUrl) => {
|
||||||
articleId={articleId}
|
setFormNewImageUrl(imageUrl);
|
||||||
/>
|
}}
|
||||||
<GalleryManager
|
articleId={articleId}
|
||||||
images={displayedImages}
|
/>
|
||||||
imageUrl={formNewImageUrl}
|
<GalleryManager
|
||||||
setImageUrl={setFormNewImageUrl}
|
images={displayedImages}
|
||||||
onAddImage={async (imageData) => {
|
imageUrl={formNewImageUrl}
|
||||||
console.log('Добавляем изображение в форме:', imageData);
|
setImageUrl={setFormNewImageUrl}
|
||||||
const newImage = await addGalleryImage(imageData);
|
onAddImage={async (imageData) => {
|
||||||
setFormNewImageUrl('');
|
console.log('Добавляем изображение в форме:', imageData);
|
||||||
setDisplayedImages(prev => [...prev, newImage]);
|
const newImage = await addGalleryImage(imageData);
|
||||||
}}
|
setFormNewImageUrl('');
|
||||||
onReorder={(images) => {
|
setDisplayedImages(prev => [...prev, newImage]);
|
||||||
console.log('Переупорядочиваем изображения в форме:', images);
|
}}
|
||||||
reorderGalleryImages(images.map(img => img.id));
|
onReorder={(images) => {
|
||||||
setDisplayedImages(images);
|
console.log('Переупорядочиваем изображения в форме:', images);
|
||||||
}}
|
reorderGalleryImages(images.map(img => img.id));
|
||||||
onDelete={(id) => {
|
setDisplayedImages(images);
|
||||||
console.log('Удаляем изображение в форме:', id);
|
}}
|
||||||
deleteGalleryImage(id);
|
onDelete={(id) => {
|
||||||
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
console.log('Удаляем изображение в форме:', id);
|
||||||
}}
|
deleteGalleryImage(id);
|
||||||
onEdit={(image) => {
|
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
||||||
console.log('Редактируем изображение в форме:', image);
|
}}
|
||||||
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
onEdit={(image) => {
|
||||||
console.error('Ошибка при редактировании изображения в форме:', err);
|
console.log('Редактируем изображение в форме:', image);
|
||||||
setError('Не удалось обновить изображение');
|
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
||||||
});
|
console.error('Ошибка при редактировании изображения в форме:', err);
|
||||||
}}
|
setError('Не удалось обновить изображение');
|
||||||
/>
|
});
|
||||||
</>
|
}}
|
||||||
) : (
|
/>
|
||||||
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
</>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -25,23 +25,23 @@ export function CitySelector({ cities, currentCity }: CitySelectorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={currentCity || ''}
|
value={currentCity || ''}
|
||||||
onChange={handleCityChange}
|
onChange={handleCityChange}
|
||||||
className="appearance-none bg-white pl-3 pr-8 py-2 text-sm font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
className="appearance-none bg-white pl-3 pr-8 py-2 text-sm font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">All Cities</option>
|
<option value="">Все столицы</option>
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<option key={city} value={city}>
|
<option key={city} value={city}>
|
||||||
{CityTitles[city]}
|
{CityTitles[city]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -25,13 +25,13 @@ export function MobileMenu({
|
|||||||
<div className="lg:hidden border-b border-gray-200">
|
<div className="lg:hidden border-b border-gray-200">
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">City</h3>
|
<h3 className="text-sm font-medium text-gray-500 mb-2">Столицы</h3>
|
||||||
<select
|
<select
|
||||||
value={currentCity || ''}
|
value={currentCity || ''}
|
||||||
onChange={onCityChange}
|
onChange={onCityChange}
|
||||||
className="w-full bg-white px-3 py-2 text-base font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full bg-white px-3 py-2 text-base font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="">All Cities</option>
|
<option value="">Все столицы</option>
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<option key={city} value={city}>
|
<option key={city} value={city}>
|
||||||
{CityTitles[city]}
|
{CityTitles[city]}
|
||||||
@ -41,18 +41,8 @@ export function MobileMenu({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Categories</h3>
|
<h3 className="text-sm font-medium text-gray-500 mb-2">Разделы</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-0">
|
||||||
<Link
|
|
||||||
to={currentCity ? `/?city=${currentCity}` : '/'}
|
|
||||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
|
||||||
!currentCategory
|
|
||||||
? 'bg-blue-50 text-blue-600'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Categories
|
|
||||||
</Link>
|
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Link
|
<Link
|
||||||
key={category}
|
key={category}
|
||||||
|
@ -10,25 +10,14 @@ interface NavigationProps {
|
|||||||
export function Navigation({ categories, currentCategory, currentCity }: NavigationProps) {
|
export function Navigation({ categories, currentCategory, currentCity }: NavigationProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-4 overflow-x-auto">
|
<div className="flex items-center space-x-4 overflow-x-auto">
|
||||||
<span className="text-sm font-medium text-gray-500"></span>
|
|
||||||
<Link
|
|
||||||
to={currentCity ? `/?city=${currentCity}` : '/'}
|
|
||||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
|
||||||
!currentCategory
|
|
||||||
? 'text-blue-600 hover:text-blue-800'
|
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Categories
|
|
||||||
</Link>
|
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Link
|
<Link
|
||||||
key={category}
|
key={category}
|
||||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`px-3 py-2 font-medium transition-colors whitespace-nowrap ${
|
||||||
Number(currentCategory) === category
|
Number(currentCategory) === category
|
||||||
? 'text-blue-600 hover:text-blue-800'
|
? 'text-base md:text-lg lg:text-xl font-semibold text-blue-600 hover:text-blue-800 bg-gray-200'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-sm md:text-base lg:text-lg font-medium text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{CategoryTitles[category]}
|
{CategoryTitles[category]}
|
||||||
|
@ -4,6 +4,7 @@ import { Search, Calendar } from 'lucide-react';
|
|||||||
import { DayPicker } from 'react-day-picker';
|
import { DayPicker } from 'react-day-picker';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import 'react-day-picker/dist/style.css';
|
import 'react-day-picker/dist/style.css';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
@ -16,11 +17,6 @@ export function SearchBar() {
|
|||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (selectedDate) {
|
|
||||||
params.set('date', format(selectedDate, 'yyyy-MM-dd'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||||
@ -36,6 +32,15 @@ export function SearchBar() {
|
|||||||
const handleDateSelect = (date: Date | undefined) => {
|
const handleDateSelect = (date: Date | undefined) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setShowDatePicker(false);
|
setShowDatePicker(false);
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('date', format(date, 'yyyy-MM-dd'));
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
params.set('q', searchQuery.trim());
|
||||||
|
}
|
||||||
|
navigate(`/search?${params.toString()}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close datepicker when clicking outside
|
// Close datepicker when clicking outside
|
||||||
@ -56,7 +61,7 @@ export function SearchBar() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск###..."
|
placeholder="Поиск..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyPress}
|
onKeyDown={handleSearchKeyPress}
|
||||||
@ -66,7 +71,7 @@ export function SearchBar() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<Search size={28} />
|
<Search size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -91,6 +96,7 @@ export function SearchBar() {
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={selectedDate}
|
selected={selectedDate}
|
||||||
onSelect={handleDateSelect}
|
onSelect={handleDateSelect}
|
||||||
|
locale={ru}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,11 +33,20 @@ 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 */}
|
||||||
<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 lg: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)}
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
@ -47,9 +56,9 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden lg:flex items-center space-x-8">
|
{/* Desktop navigation - single line for large screens */}
|
||||||
|
<nav className="hidden xl:flex items-center space-x-8">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm font-medium text-gray-500"></span>
|
|
||||||
<CitySelector cities={CityIds} currentCity={currentCity} />
|
<CitySelector cities={CityIds} currentCity={currentCity} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -77,6 +86,27 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tablet navigation - two lines for medium screens */}
|
||||||
|
<div className="hidden lg:block xl:hidden border-t border-gray-100">
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
{/* First line: City selector */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<CitySelector cities={CityIds} currentCity={currentCity} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second line: Categories navigation */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Navigation
|
||||||
|
categories={CategoryIds}
|
||||||
|
currentCategory={currentCategory}
|
||||||
|
currentCity={currentCity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
|
@ -6,7 +6,7 @@ import { CategoryIds, CategoryTitles, CityIds, CityTitles } from '../types';
|
|||||||
import { useBookmarkStore } from "../stores/bookmarkStore";
|
import { useBookmarkStore } from "../stores/bookmarkStore";
|
||||||
|
|
||||||
|
|
||||||
export function Header() {
|
export function HeaderMono() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
@ -5,7 +5,7 @@ import { ArticleList } from '../components/ArticleList';
|
|||||||
import { ArticleForm } from '../components/ArticleForm';
|
import { ArticleForm } from '../components/ArticleForm';
|
||||||
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
||||||
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
||||||
import { Article, Author, GalleryImage, ArticleData, AuthorRole } from '../types';
|
import { Article, Author, GalleryImage, ArticleData, AuthorRole, AuthorLink } from '../types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { FileJson, Users, UserSquare2 } from "lucide-react";
|
import { FileJson, Users, UserSquare2 } from "lucide-react";
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
@ -19,10 +19,7 @@ interface FormState {
|
|||||||
coverImage: string;
|
coverImage: string;
|
||||||
readTime: number;
|
readTime: number;
|
||||||
content: string;
|
content: string;
|
||||||
authors: {
|
authors: AuthorLink[];
|
||||||
role: AuthorRole;
|
|
||||||
author: Author;
|
|
||||||
}[];
|
|
||||||
galleryImages: GalleryImage[];
|
galleryImages: GalleryImage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +57,14 @@ export function AdminPage() {
|
|||||||
fetchAuthors();
|
fetchAuthors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function normalizeAuthors(authors: AuthorLink[]): AuthorLink[] {
|
||||||
|
return authors.map(({ authorId, role, author }) => ({
|
||||||
|
authorId,
|
||||||
|
role,
|
||||||
|
author,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const article = articles.find(a => a.id === id);
|
const article = articles.find(a => a.id === id);
|
||||||
@ -74,8 +79,8 @@ export function AdminPage() {
|
|||||||
coverImage: article.coverImage,
|
coverImage: article.coverImage,
|
||||||
readTime: article.readTime,
|
readTime: article.readTime,
|
||||||
content: article.content,
|
content: article.content,
|
||||||
authors: article.authors ?? [],
|
authors: normalizeAuthors(article.authors) ?? [],
|
||||||
galleryImages: [],
|
galleryImages: article.gallery ?? [],
|
||||||
});
|
});
|
||||||
setArticleId(article.id);
|
setArticleId(article.id);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
import { useParams, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { ArrowLeft, Clock } from 'lucide-react';
|
import { ArrowLeft, Clock } from 'lucide-react';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { ReactionButtons } from '../components/ReactionButtons';
|
import { ReactionButtons } from '../components/ReactionButtons';
|
||||||
import { PhotoGallery } from '../components/PhotoGallery';
|
import { PhotoGallery } from '../components/PhotoGallery';
|
||||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||||
import { SEO } from '../components/SEO';
|
import { SEO } from '../components/SEO';
|
||||||
|
import { useScrollStore } from '../stores/scrollStore';
|
||||||
import { ArticleContent } from '../components/ArticleContent';
|
import { ArticleContent } from '../components/ArticleContent';
|
||||||
import { ShareButton } from '../components/ShareButton';
|
import { ShareButton } from '../components/ShareButton';
|
||||||
import { BookmarkButton } from '../components/BookmarkButton';
|
import { BookmarkButton } from '../components/BookmarkButton';
|
||||||
@ -16,13 +17,30 @@ import api from "../utils/api";
|
|||||||
export function ArticlePage() {
|
export function ArticlePage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [articleData, setArticleData] = useState<Article | null>(null);
|
const [articleData, setArticleData] = useState<Article | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const restoreHomeScrollPosition = useScrollStore(state => state.restoreHomeScrollPosition);
|
||||||
|
|
||||||
// Получть предыдущее состояние местоположения или создать путь по умолчанию
|
// Получть предыдущее состояние местоположения или создать путь по умолчанию
|
||||||
const backTo = location.state?.from || '/';
|
const backTo = location.state?.from || '/';
|
||||||
|
|
||||||
|
const handleBackClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// При возврате на главную страницу восстановливается положение скролинга
|
||||||
|
if (backTo === '/' || backTo.startsWith('/?')) {
|
||||||
|
navigate(backTo);
|
||||||
|
// Восстановление положения скролинга после навигации
|
||||||
|
setTimeout(() => {
|
||||||
|
restoreHomeScrollPosition();
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
navigate(backTo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchArticle = async () => {
|
const fetchArticle = async () => {
|
||||||
try {
|
try {
|
||||||
@ -114,13 +132,13 @@ export function ArticlePage() {
|
|||||||
/>
|
/>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<Link
|
<button
|
||||||
to={backTo}
|
onClick={handleBackClick}
|
||||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8"
|
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} className="mr-2" />
|
<ArrowLeft size={20} className="mr-2" />
|
||||||
К списку статей
|
К списку статей
|
||||||
</Link>
|
</button>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
{/* Article Header */}
|
{/* Article Header */}
|
||||||
|
@ -19,6 +19,7 @@ import { imageResolutions } from '../config/imageResolutions';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import RolesWord from "../components/Words/RolesWord";
|
import RolesWord from "../components/Words/RolesWord";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import {AuthorRole} from "../types";
|
||||||
|
|
||||||
|
|
||||||
const roleIcons: Record<string, JSX.Element> = {
|
const roleIcons: Record<string, JSX.Element> = {
|
||||||
@ -484,7 +485,7 @@ export function AuthorManagementPage() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span><RolesWord role={role} /></span>
|
<span><RolesWord role={role as AuthorRole} /></span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { FeaturedSection } from '../components/FeaturedSection';
|
import { FeaturedSection } from '../components/FeaturedSection';
|
||||||
import { AuthorsSection } from '../components/AuthorsSection';
|
import { AuthorsSection } from '../components/AuthorsSection';
|
||||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||||
|
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 { MasterBio } from "../components/MasterBio";
|
import { MasterBio } from "../components/MasterBio";
|
||||||
@ -12,6 +14,16 @@ export function HomePage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const categoryId = searchParams.get('category');
|
const categoryId = searchParams.get('category');
|
||||||
const backgroundImage= BackgroundImages[Number(categoryId)];
|
const backgroundImage= BackgroundImages[Number(categoryId)];
|
||||||
|
const restoreHomeScrollPosition = useScrollStore(state => state.restoreHomeScrollPosition);
|
||||||
|
|
||||||
|
// Восстановление позиции скролинга, когда возвращаемся на главную страницу
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверка, возвращаемся ли мы к статье (сохранено положение скролинга).
|
||||||
|
const hasStoredPosition = useScrollStore.getState().homeScrollPosition > 0;
|
||||||
|
if (hasStoredPosition) {
|
||||||
|
restoreHomeScrollPosition();
|
||||||
|
}
|
||||||
|
}, [restoreHomeScrollPosition]);
|
||||||
|
|
||||||
const getHeroTitle = () => {
|
const getHeroTitle = () => {
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
|
@ -6,6 +6,7 @@ import { Pagination } from '../components/Pagination';
|
|||||||
import { Article } from '../types';
|
import { Article } from '../types';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
|
||||||
|
|
||||||
const ARTICLES_PER_PAGE = 9;
|
const ARTICLES_PER_PAGE = 9;
|
||||||
@ -65,7 +66,7 @@ export function SearchPage() {
|
|||||||
return `Статьи автора ${authorName}`;
|
return `Статьи автора ${authorName}`;
|
||||||
}
|
}
|
||||||
if (date) {
|
if (date) {
|
||||||
return `Статьи за ${format(parseISO(date), 'MMMM d, yyyy')}`;
|
return `Статьи за ${format(parseISO(date), 'd MMMM, yyyy', { locale: ru })}`;
|
||||||
}
|
}
|
||||||
return query ? `Результаты поиска "${query}"` : 'Поиск статей';
|
return query ? `Результаты поиска "${query}"` : 'Поиск статей';
|
||||||
};
|
};
|
||||||
|
26
src/stores/scrollStore.ts
Normal file
26
src/stores/scrollStore.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ScrollState {
|
||||||
|
homeScrollPosition: number;
|
||||||
|
setHomeScrollPosition: (position: number) => void;
|
||||||
|
restoreHomeScrollPosition: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useScrollStore = create<ScrollState>((set, get) => ({
|
||||||
|
homeScrollPosition: 0,
|
||||||
|
setHomeScrollPosition: (position) => set({ homeScrollPosition: position }),
|
||||||
|
restoreHomeScrollPosition: () => {
|
||||||
|
const { homeScrollPosition } = get();
|
||||||
|
const tryScroll = () => {
|
||||||
|
if (document.body.scrollHeight >= homeScrollPosition + window.innerHeight) {
|
||||||
|
window.scrollTo({
|
||||||
|
top: homeScrollPosition,
|
||||||
|
behavior: 'instant'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(tryScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tryScroll);
|
||||||
|
}
|
||||||
|
}));
|
@ -6,10 +6,7 @@ export interface Article {
|
|||||||
content: string;
|
content: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
cityId: number;
|
cityId: number;
|
||||||
authors: {
|
authors: AuthorLink[];
|
||||||
role: AuthorRole;
|
|
||||||
author: Author;
|
|
||||||
}[];
|
|
||||||
authorName: string;
|
authorName: string;
|
||||||
coAuthorName: string;
|
coAuthorName: string;
|
||||||
photographerName: string;
|
photographerName: string;
|
||||||
@ -44,10 +41,7 @@ export interface ArticleData {
|
|||||||
content: string;
|
content: string;
|
||||||
importId: number;
|
importId: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
authors: {
|
authors: AuthorLink[];
|
||||||
role: AuthorRole;
|
|
||||||
author: Author;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Структура ответа на список статей
|
// Структура ответа на список статей
|
||||||
@ -85,6 +79,12 @@ export interface Author {
|
|||||||
roles: string;
|
roles: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthorLink {
|
||||||
|
authorId: string;
|
||||||
|
role: AuthorRole;
|
||||||
|
author: Author;
|
||||||
|
}
|
||||||
|
|
||||||
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];
|
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];
|
||||||
export const CityIds: number[] = [1 , 2];
|
export const CityIds: number[] = [1 , 2];
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user