Переход от монолитной 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 { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import MinutesWord from './Words/MinutesWord';
|
||||
|
||||
import { useScrollStore } from '../stores/scrollStore';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
@ -11,11 +11,19 @@ interface ArticleCardProps {
|
||||
|
||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
const location = useLocation();
|
||||
const setHomeScrollPosition = useScrollStore(state => state.setHomeScrollPosition);
|
||||
|
||||
const writerAuthors = article.authors
|
||||
.filter(a => a.role === 'WRITER')
|
||||
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||
|
||||
const handleArticleClick = () => {
|
||||
// Сохранить текущее положение скролинга при переходе к статье
|
||||
if (location.pathname === '/') {
|
||||
setHomeScrollPosition(window.scrollY);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||
@ -76,6 +84,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
<Link
|
||||
to={`/article/${article.id}`}
|
||||
state={{ from: location.pathname + location.search }}
|
||||
onClick={handleArticleClick}
|
||||
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 { GalleryManager } from './GalleryManager';
|
||||
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 ConfirmModal from './ConfirmModal';
|
||||
import RolesWord from "./Words/RolesWord";
|
||||
import { Trash2, UserPlus } from "lucide-react";
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
|
||||
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
||||
@ -56,6 +57,7 @@ export function ArticleForm({
|
||||
}: ArticleFormProps) {
|
||||
const { user } = useAuthStore();
|
||||
const isAdmin = user?.permissions.isAdmin || false;
|
||||
const showGallery = false;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
@ -75,14 +77,15 @@ export function ArticleForm({
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [newAuthorId, setNewAuthorId] = useState('');
|
||||
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 [selectedAuthors, setSelectedAuthors] = useState<Array<{
|
||||
authorId: string;
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}>>([]);
|
||||
const [selectedAuthors, setSelectedAuthors] = useState<AuthorLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormState) setFormReady(true);
|
||||
}, [initialFormState]);
|
||||
|
||||
// Добавляем обработку ошибок
|
||||
useEffect(() => {
|
||||
@ -135,12 +138,27 @@ export function ArticleForm({
|
||||
}, [initialFormState]);
|
||||
|
||||
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 hasFormChanges = Object.keys(initialFormState).some(key => {
|
||||
if (!formReady) return false;
|
||||
|
||||
if (key === 'galleryImages') {
|
||||
if (!showGallery) return false; // 💡 игнорировать при выключенной галерее
|
||||
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||
if (isInitialLoad && isDifferent) return false;
|
||||
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 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);
|
||||
@ -158,7 +177,7 @@ export function ArticleForm({
|
||||
if (isInitialLoad) {
|
||||
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(
|
||||
(a) =>
|
||||
@ -195,10 +214,7 @@ export function ArticleForm({
|
||||
content: content || '',
|
||||
importId: 0,
|
||||
isActive: false,
|
||||
authors: selectedAuthors.map(a => ({
|
||||
author: a.author,
|
||||
role: a.role,
|
||||
}))
|
||||
authors: selectedAuthors,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -391,51 +407,55 @@ export function ArticleForm({
|
||||
articleId={articleId}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Галерея</span>
|
||||
</label>
|
||||
{editingId ? (
|
||||
<>
|
||||
<ImageUploader
|
||||
onUploadComplete={(imageUrl) => {
|
||||
setFormNewImageUrl(imageUrl);
|
||||
}}
|
||||
articleId={articleId}
|
||||
/>
|
||||
<GalleryManager
|
||||
images={displayedImages}
|
||||
imageUrl={formNewImageUrl}
|
||||
setImageUrl={setFormNewImageUrl}
|
||||
onAddImage={async (imageData) => {
|
||||
console.log('Добавляем изображение в форме:', imageData);
|
||||
const newImage = await addGalleryImage(imageData);
|
||||
setFormNewImageUrl('');
|
||||
setDisplayedImages(prev => [...prev, newImage]);
|
||||
}}
|
||||
onReorder={(images) => {
|
||||
console.log('Переупорядочиваем изображения в форме:', images);
|
||||
reorderGalleryImages(images.map(img => img.id));
|
||||
setDisplayedImages(images);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
console.log('Удаляем изображение в форме:', id);
|
||||
deleteGalleryImage(id);
|
||||
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
||||
}}
|
||||
onEdit={(image) => {
|
||||
console.log('Редактируем изображение в форме:', image);
|
||||
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
||||
console.error('Ошибка при редактировании изображения в форме:', err);
|
||||
setError('Не удалось обновить изображение');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGallery && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Галерея</span>
|
||||
</label>
|
||||
{editingId ? (
|
||||
<>
|
||||
<ImageUploader
|
||||
onUploadComplete={(imageUrl) => {
|
||||
setFormNewImageUrl(imageUrl);
|
||||
}}
|
||||
articleId={articleId}
|
||||
/>
|
||||
<GalleryManager
|
||||
images={displayedImages}
|
||||
imageUrl={formNewImageUrl}
|
||||
setImageUrl={setFormNewImageUrl}
|
||||
onAddImage={async (imageData) => {
|
||||
console.log('Добавляем изображение в форме:', imageData);
|
||||
const newImage = await addGalleryImage(imageData);
|
||||
setFormNewImageUrl('');
|
||||
setDisplayedImages(prev => [...prev, newImage]);
|
||||
}}
|
||||
onReorder={(images) => {
|
||||
console.log('Переупорядочиваем изображения в форме:', images);
|
||||
reorderGalleryImages(images.map(img => img.id));
|
||||
setDisplayedImages(images);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
console.log('Удаляем изображение в форме:', id);
|
||||
deleteGalleryImage(id);
|
||||
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
||||
}}
|
||||
onEdit={(image) => {
|
||||
console.log('Редактируем изображение в форме:', image);
|
||||
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
||||
console.error('Ошибка при редактировании изображения в форме:', err);
|
||||
setError('Не удалось обновить изображение');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -25,23 +25,23 @@ export function CitySelector({ cities, currentCity }: CitySelectorProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentCity || ''}
|
||||
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"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{CityTitles[city]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentCity || ''}
|
||||
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"
|
||||
>
|
||||
<option value="">Все столицы</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{CityTitles[city]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -25,13 +25,13 @@ export function MobileMenu({
|
||||
<div className="lg:hidden border-b border-gray-200">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<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
|
||||
value={currentCity || ''}
|
||||
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"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
<option value="">Все столицы</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{CityTitles[city]}
|
||||
@ -41,18 +41,8 @@ export function MobileMenu({
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Categories</h3>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Разделы</h3>
|
||||
<div className="space-y-0">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
|
@ -10,25 +10,14 @@ interface NavigationProps {
|
||||
export function Navigation({ categories, currentCategory, currentCity }: NavigationProps) {
|
||||
return (
|
||||
<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) => (
|
||||
<Link
|
||||
key={category}
|
||||
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
|
||||
? 'text-blue-600 hover:text-blue-800'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
? 'text-base md:text-lg lg:text-xl font-semibold text-blue-600 hover:text-blue-800 bg-gray-200'
|
||||
: 'text-sm md:text-base lg:text-lg font-medium text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{CategoryTitles[category]}
|
||||
|
@ -4,6 +4,7 @@ import { Search, Calendar } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { format } from 'date-fns';
|
||||
import 'react-day-picker/dist/style.css';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
|
||||
export function SearchBar() {
|
||||
@ -16,11 +17,6 @@ export function SearchBar() {
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedDate) {
|
||||
params.set('date', format(selectedDate, 'yyyy-MM-dd'));
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
@ -36,6 +32,15 @@ export function SearchBar() {
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
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
|
||||
@ -56,7 +61,7 @@ export function SearchBar() {
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск###..."
|
||||
placeholder="Поиск..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyPress}
|
||||
@ -66,7 +71,7 @@ export function SearchBar() {
|
||||
type="submit"
|
||||
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Search size={28} />
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -91,6 +96,7 @@ export function SearchBar() {
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
locale={ru}
|
||||
className="p-3"
|
||||
/>
|
||||
</div>
|
||||
|
@ -33,11 +33,20 @@ export function Header() {
|
||||
|
||||
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
|
||||
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)}
|
||||
>
|
||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
@ -46,10 +55,10 @@ export function Header() {
|
||||
<Logo />
|
||||
</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">
|
||||
<span className="text-sm font-medium text-gray-500"></span>
|
||||
<CitySelector cities={CityIds} currentCity={currentCity} />
|
||||
</div>
|
||||
|
||||
@ -77,6 +86,27 @@ export function Header() {
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
<MobileMenu
|
||||
|
@ -6,7 +6,7 @@ import { CategoryIds, CategoryTitles, CityIds, CityTitles } from '../types';
|
||||
import { useBookmarkStore } from "../stores/bookmarkStore";
|
||||
|
||||
|
||||
export function Header() {
|
||||
export function HeaderMono() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const location = useLocation();
|
@ -5,7 +5,7 @@ import { ArticleList } from '../components/ArticleList';
|
||||
import { ArticleForm } from '../components/ArticleForm';
|
||||
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
||||
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 { FileJson, Users, UserSquare2 } from "lucide-react";
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -19,10 +19,7 @@ interface FormState {
|
||||
coverImage: string;
|
||||
readTime: number;
|
||||
content: string;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
authors: AuthorLink[];
|
||||
galleryImages: GalleryImage[];
|
||||
}
|
||||
|
||||
@ -60,6 +57,14 @@ export function AdminPage() {
|
||||
fetchAuthors();
|
||||
}, []);
|
||||
|
||||
function normalizeAuthors(authors: AuthorLink[]): AuthorLink[] {
|
||||
return authors.map(({ authorId, role, author }) => ({
|
||||
authorId,
|
||||
role,
|
||||
author,
|
||||
}));
|
||||
}
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: string) => {
|
||||
const article = articles.find(a => a.id === id);
|
||||
@ -74,8 +79,8 @@ export function AdminPage() {
|
||||
coverImage: article.coverImage,
|
||||
readTime: article.readTime,
|
||||
content: article.content,
|
||||
authors: article.authors ?? [],
|
||||
galleryImages: [],
|
||||
authors: normalizeAuthors(article.authors) ?? [],
|
||||
galleryImages: article.gallery ?? [],
|
||||
});
|
||||
setArticleId(article.id);
|
||||
setShowForm(true);
|
||||
|
@ -1,11 +1,12 @@
|
||||
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 { Header } from '../components/Header';
|
||||
import { ReactionButtons } from '../components/ReactionButtons';
|
||||
import { PhotoGallery } from '../components/PhotoGallery';
|
||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||
import { SEO } from '../components/SEO';
|
||||
import { useScrollStore } from '../stores/scrollStore';
|
||||
import { ArticleContent } from '../components/ArticleContent';
|
||||
import { ShareButton } from '../components/ShareButton';
|
||||
import { BookmarkButton } from '../components/BookmarkButton';
|
||||
@ -16,13 +17,30 @@ import api from "../utils/api";
|
||||
export function ArticlePage() {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [articleData, setArticleData] = useState<Article | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const restoreHomeScrollPosition = useScrollStore(state => state.restoreHomeScrollPosition);
|
||||
|
||||
// Получть предыдущее состояние местоположения или создать путь по умолчанию
|
||||
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(() => {
|
||||
const fetchArticle = async () => {
|
||||
try {
|
||||
@ -114,13 +132,13 @@ export function ArticlePage() {
|
||||
/>
|
||||
<Header />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link
|
||||
to={backTo}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8"
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} className="mr-2" />
|
||||
К списку статей
|
||||
</Link>
|
||||
</button>
|
||||
|
||||
<article>
|
||||
{/* Article Header */}
|
||||
|
@ -19,6 +19,7 @@ import { imageResolutions } from '../config/imageResolutions';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import RolesWord from "../components/Words/RolesWord";
|
||||
import axios from "axios";
|
||||
import {AuthorRole} from "../types";
|
||||
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { FeaturedSection } from '../components/FeaturedSection';
|
||||
import { AuthorsSection } from '../components/AuthorsSection';
|
||||
import { BackgroundImages } from '../hooks/useBackgroundImage';
|
||||
import { useScrollStore } from '../stores/scrollStore';
|
||||
import { CategoryDescription, CategoryText, CategoryTitles } from '../types';
|
||||
import { SEO } from '../components/SEO';
|
||||
import { MasterBio } from "../components/MasterBio";
|
||||
@ -12,6 +14,16 @@ export function HomePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const categoryId = searchParams.get('category');
|
||||
const backgroundImage= BackgroundImages[Number(categoryId)];
|
||||
const restoreHomeScrollPosition = useScrollStore(state => state.restoreHomeScrollPosition);
|
||||
|
||||
// Восстановление позиции скролинга, когда возвращаемся на главную страницу
|
||||
useEffect(() => {
|
||||
// Проверка, возвращаемся ли мы к статье (сохранено положение скролинга).
|
||||
const hasStoredPosition = useScrollStore.getState().homeScrollPosition > 0;
|
||||
if (hasStoredPosition) {
|
||||
restoreHomeScrollPosition();
|
||||
}
|
||||
}, [restoreHomeScrollPosition]);
|
||||
|
||||
const getHeroTitle = () => {
|
||||
if (categoryId) {
|
||||
|
@ -6,6 +6,7 @@ import { Pagination } from '../components/Pagination';
|
||||
import { Article } from '../types';
|
||||
import api from '../utils/api';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
|
||||
const ARTICLES_PER_PAGE = 9;
|
||||
@ -65,7 +66,7 @@ export function SearchPage() {
|
||||
return `Статьи автора ${authorName}`;
|
||||
}
|
||||
if (date) {
|
||||
return `Статьи за ${format(parseISO(date), 'MMMM d, yyyy')}`;
|
||||
return `Статьи за ${format(parseISO(date), 'd MMMM, yyyy', { locale: ru })}`;
|
||||
}
|
||||
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;
|
||||
categoryId: number;
|
||||
cityId: number;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
authors: AuthorLink[];
|
||||
authorName: string;
|
||||
coAuthorName: string;
|
||||
photographerName: string;
|
||||
@ -44,10 +41,7 @@ export interface ArticleData {
|
||||
content: string;
|
||||
importId: number;
|
||||
isActive: boolean;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
authors: AuthorLink[];
|
||||
}
|
||||
|
||||
// Структура ответа на список статей
|
||||
@ -85,6 +79,12 @@ export interface Author {
|
||||
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 CityIds: number[] = [1 , 2];
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user