From 9ca069c49b415583a3a011b75e20e9c5f1127ead Mon Sep 17 00:00:00 2001 From: anibilag Date: Tue, 17 Jun 2025 23:26:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20Header=20=D0=BA=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D1=83=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2.=20=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D1=82=D0=B5.?= =?UTF-8?q?=20=D0=97=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=BB=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B5=20?= =?UTF-8?q?=D0=BA=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D0=B5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 9 ++ src/components/ArticleCard.tsx | 11 +- src/components/ArticleForm.tsx | 138 ++++++++++-------- src/components/Header/CitySelector.tsx | 36 ++--- src/components/Header/MobileMenu.tsx | 18 +-- src/components/Header/Navigation.tsx | 17 +-- src/components/Header/SearchBar.tsx | 20 ++- src/components/Header/index.tsx | 38 ++++- src/components/{Header.tsx => HeaderMono.tsx} | 2 +- src/pages/AdminPage.tsx | 19 ++- src/pages/ArticlePage.tsx | 28 +++- src/pages/AuthorManagementPage.tsx | 3 +- src/pages/HomePage.tsx | 12 ++ src/pages/SearchPage.tsx | 3 +- src/stores/scrollStore.ts | 26 ++++ src/types/index.ts | 16 +- 16 files changed, 256 insertions(+), 140 deletions(-) create mode 100644 Dockerfile rename src/components/{Header.tsx => HeaderMono.tsx} (99%) create mode 100644 src/stores/scrollStore.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e46692 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index bc6d375..6973daa 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -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 (
Читать → diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index 581e918..f97c67b 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -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 = { @@ -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>([]); + const [selectedAuthors, setSelectedAuthors] = useState([]); + + 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} /> -
- - {editingId ? ( - <> - { - setFormNewImageUrl(imageUrl); - }} - articleId={articleId} - /> - { - 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('Не удалось обновить изображение'); - }); - }} - /> - - ) : ( -

Галерея доступна после создания статьи.

- )} -
+ + {showGallery && ( +
+ + {editingId ? ( + <> + { + setFormNewImageUrl(imageUrl); + }} + articleId={articleId} + /> + { + 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('Не удалось обновить изображение'); + }); + }} + /> + + ) : ( +

Галерея доступна после создания статьи.

+ )} +
+ )} +
+
+ + +
); } \ No newline at end of file diff --git a/src/components/Header/MobileMenu.tsx b/src/components/Header/MobileMenu.tsx index d7ae615..868b635 100644 --- a/src/components/Header/MobileMenu.tsx +++ b/src/components/Header/MobileMenu.tsx @@ -25,13 +25,13 @@ export function MobileMenu({
-

City

+

Столицы

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" > - +
@@ -91,6 +96,7 @@ export function SearchBar() { mode="single" selected={selectedDate} onSelect={handleDateSelect} + locale={ru} className="p-3" />
diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index aa569fd..f28fb8b 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -33,11 +33,20 @@ export function Header() { return (
+ {/* Название блога над навигацией */} +
+
+

+ Новая Культура Двух Столиц +

+
+
+ {/* Main header row */}
- -
+ + {/* Tablet navigation - two lines for medium screens */} +
+
+
+ {/* First line: City selector */} +
+ +
+ + {/* Second line: Categories navigation */} +
+ +
+
+
+
({ + 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); diff --git a/src/pages/ArticlePage.tsx b/src/pages/ArticlePage.tsx index 5e7f988..03c4789 100644 --- a/src/pages/ArticlePage.tsx +++ b/src/pages/ArticlePage.tsx @@ -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
(null); const [error, setError] = useState(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() { />
- К списку статей - +
{/* Article Header */} diff --git a/src/pages/AuthorManagementPage.tsx b/src/pages/AuthorManagementPage.tsx index fe90b6e..b5c2a02 100644 --- a/src/pages/AuthorManagementPage.tsx +++ b/src/pages/AuthorManagementPage.tsx @@ -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 = { @@ -484,7 +485,7 @@ export function AuthorManagementPage() { }); }} /> - + ))} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 9093ca6..e47706b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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) { diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 3dc98a6..e1ad7eb 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -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}"` : 'Поиск статей'; }; diff --git a/src/stores/scrollStore.ts b/src/stores/scrollStore.ts new file mode 100644 index 0000000..a19b890 --- /dev/null +++ b/src/stores/scrollStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; + +interface ScrollState { + homeScrollPosition: number; + setHomeScrollPosition: (position: number) => void; + restoreHomeScrollPosition: () => void; +} + +export const useScrollStore = create((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); + } +})); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 7f8ed13..df71536 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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];