From 4de854664f383657bd98d977bccc73057f0efc63 Mon Sep 17 00:00:00 2001 From: Antonina Ivanova Date: Tue, 30 Dec 2025 15:07:39 +0200 Subject: [PATCH] feat: implement timeline management UI for chronology block #129 --- .../constants/frontend-routes.constants.ts | 1 + src/app/router/Routes.tsx | 5 + src/app/stores/timeline-store.ts | 7 + src/features/AdminPage/AdminBar.component.tsx | 1 + .../ChronologyModal.component.tsx | 365 ++++++++++++++++++ .../ChronologyModal.styles.scss | 56 +++ .../ChronologyPage.component.tsx | 178 +++++++++ .../ChronologyPage/ChronologyPage.styles.scss | 68 ++++ 8 files changed, 681 insertions(+) create mode 100644 src/features/AdminPage/ChronologyPage/ChronologyModal/ChronologyModal.component.tsx create mode 100644 src/features/AdminPage/ChronologyPage/ChronologyModal/ChronologyModal.styles.scss create mode 100644 src/features/AdminPage/ChronologyPage/ChronologyPage.component.tsx create mode 100644 src/features/AdminPage/ChronologyPage/ChronologyPage.styles.scss diff --git a/src/app/common/constants/frontend-routes.constants.ts b/src/app/common/constants/frontend-routes.constants.ts index b4656bd..8fd1099 100644 --- a/src/app/common/constants/frontend-routes.constants.ts +++ b/src/app/common/constants/frontend-routes.constants.ts @@ -11,6 +11,7 @@ const FRONTEND_ROUTES = { TEAM: '/admin-panel/team', ANALYTICS: '/admin-panel/analytics', NEWS: '/admin-panel/news', + CHRONOLOGY: '/admin-panel/chronology', }, OTHER_PAGES: { CATALOG: '/catalog', diff --git a/src/app/router/Routes.tsx b/src/app/router/Routes.tsx index 709868d..9feb1c1 100644 --- a/src/app/router/Routes.tsx +++ b/src/app/router/Routes.tsx @@ -9,6 +9,7 @@ import PartnersPage from '@/features/AdditionalPages/PartnersPage/Partners.compo import AdminPage from '@/features/AdminPage/AdminPage.component'; import Partners from '@/features/AdminPage/PartnersPage/Partners.component'; import TeamPage from '@/features/AdminPage/TeamPage/TeamPage.component'; +import ChronologyPage from '@/features/AdminPage/ChronologyPage/ChronologyPage.component'; import StreetcodeCatalog from '@/features/StreetcodeCatalogPage/StreetcodeCatalog.component'; import NewsPage from '@/features/AdditionalPages/NewsPage/News.component'; import ContactUs from '@/features/AdditionalPages/ContactUsPage/ContanctUs.component'; @@ -41,6 +42,10 @@ const router = createBrowserRouter(createRoutesFromElements( )} /> + } + /> } /> } /> } /> diff --git a/src/app/stores/timeline-store.ts b/src/app/stores/timeline-store.ts index f7defb0..5b933e7 100644 --- a/src/app/stores/timeline-store.ts +++ b/src/app/stores/timeline-store.ts @@ -109,6 +109,13 @@ export default class TimelineStore { } catch (error: unknown) { /* empty */ } }; + public fetchAllTimelineItems = async () => { + try { + const timelineItems = await timelineApi.getAll(); + this.setInternalMap(timelineItems); + } catch (error: unknown) { /* empty */ } + }; + public createTimelineItem = async (timelineItem: TimelineItem) => { try { await timelineApi.create(timelineItem); diff --git a/src/features/AdminPage/AdminBar.component.tsx b/src/features/AdminPage/AdminBar.component.tsx index 6c90552..4bbfc70 100644 --- a/src/features/AdminPage/AdminBar.component.tsx +++ b/src/features/AdminPage/AdminBar.component.tsx @@ -7,6 +7,7 @@ const AdminBar = () => ( Для фанів Партнери Команда + Хронологія ); diff --git a/src/features/AdminPage/ChronologyPage/ChronologyModal/ChronologyModal.component.tsx b/src/features/AdminPage/ChronologyPage/ChronologyModal/ChronologyModal.component.tsx new file mode 100644 index 0000000..a38657a --- /dev/null +++ b/src/features/AdminPage/ChronologyPage/ChronologyModal/ChronologyModal.component.tsx @@ -0,0 +1,365 @@ +import './ChronologyModal.styles.scss'; +import '@features/AdminPage/AdminModal.styles.scss'; + +import CancelBtn from '@images/utils/Cancel_btn.svg'; + +import { observer } from 'mobx-react-lite'; +import React, { useEffect, useState } from 'react'; +import TimelineItem, { + DateViewPattern, + HistoricalContext, + selectDateOptionsforTimeline, +} from '@models/timeline/chronology.model'; +import useMobx from '@stores/root-store'; + +import { + Button, + DatePicker, + Form, + Input, + Modal, + Select, +} from 'antd'; +import TextArea from 'antd/es/input/TextArea'; +import dayjs, { Dayjs } from 'dayjs'; +import 'dayjs/locale/uk'; + +dayjs.locale('uk'); + +const { Option } = Select; + +const ChronologyModal: React.FC<{ + timelineItem?: TimelineItem; + open: boolean; + setIsModalOpen: React.Dispatch>; + afterSubmit?: () => void; +}> = observer(({ timelineItem, open, setIsModalOpen, afterSubmit }) => { + const [form] = Form.useForm(); + const { timelineItemStore, historicalContextStore } = useMobx(); + const [dateType, setDateType] = useState<'date' | 'month' | 'year' | 'season-year'>('date'); + const [selectedDate, setSelectedDate] = useState(null); + const [contexts, setContexts] = useState([]); + const [selectedContexts, setSelectedContexts] = useState([]); + const [titleCount, setTitleCount] = useState(0); + const [descriptionCount, setDescriptionCount] = useState(0); + const [contextInputValue, setContextInputValue] = useState(''); + const [contextError, setContextError] = useState(''); + + const maxTitleLength = 28; + const maxDescriptionLength = 400; + const maxContextLength = 50; + + useEffect(() => { + historicalContextStore.fetchHistoricalContextAll().then(() => { + setContexts(historicalContextStore.historicalContextArray); + }); + }, []); + + useEffect(() => { + if (timelineItem && open) { + const date = dayjs(timelineItem.date); + setSelectedDate(date); + + let type: 'date' | 'month' | 'year' | 'season-year' = 'date'; + switch (timelineItem.dateViewPattern) { + case DateViewPattern.Year: + type = 'year'; + break; + case DateViewPattern.MonthYear: + type = 'month'; + break; + case DateViewPattern.SeasonYear: + type = 'season-year'; + break; + default: + type = 'date'; + } + setDateType(type); + + form.setFieldsValue({ + title: timelineItem.title, + description: timelineItem.description, + dateType: type, + historicalContexts: timelineItem.historicalContexts?.map((c) => c.id) || [], + }); + + setTitleCount(timelineItem.title?.length || 0); + setDescriptionCount(timelineItem.description?.length || 0); + setSelectedContexts(timelineItem.historicalContexts?.map((c) => c.id) || []); + } else if (open) { + form.resetFields(); + setSelectedDate(null); + setDateType('date'); + setSelectedContexts([]); + setTitleCount(0); + setDescriptionCount(0); + setContextInputValue(''); + setContextError(''); + } + }, [timelineItem, open, form]); + + const handleCancel = () => { + form.resetFields(); + setIsModalOpen(false); + setSelectedDate(null); + setSelectedContexts([]); + setTitleCount(0); + setDescriptionCount(0); + setContextInputValue(''); + setContextError(''); + }; + + const getDateViewPattern = (type: string): DateViewPattern => { + switch (type) { + case 'year': + return DateViewPattern.Year; + case 'month': + return DateViewPattern.MonthYear; + case 'season-year': + return DateViewPattern.SeasonYear; + default: + return DateViewPattern.DateMonthYear; + } + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + if (!selectedDate) { + form.setFields([{ name: 'date', errors: ['Дата обов\'язкова'] }]); + return; + } + + const selectedContextObjects = contexts.filter((c) => + selectedContexts.includes(c.id) + ); + + const timelineData: TimelineItem = { + id: timelineItem?.id || 0, + date: selectedDate.toDate(), + dateViewPattern: getDateViewPattern(dateType), + title: values.title.trim(), + description: values.description?.trim() || '', + historicalContexts: selectedContextObjects, + }; + + if (timelineItem) { + await timelineItemStore.updateTimelineItem(timelineData); + } else { + await timelineItemStore.createTimelineItem(timelineData); + } + + if (afterSubmit) { + afterSubmit(); + } + handleCancel(); + } catch (error) { + console.error('Form validation failed:', error); + } + }; + + const handleDateTypeChange = (value: 'date' | 'month' | 'year' | 'season-year') => { + setDateType(value); + setSelectedDate(null); + form.setFieldsValue({ date: null }); + }; + + const getDatePicker = () => { + const commonProps = { + value: selectedDate, + onChange: (date: Dayjs | null) => { + setSelectedDate(date); + form.setFieldsValue({ date }); + }, + format: dateType === 'year' ? 'YYYY' : dateType === 'month' ? 'YYYY, MMMM' : 'YYYY, D MMMM', + placeholder: 'Оберіть дату', + style: { width: '100%' }, + }; + + if (dateType === 'year') { + return ; + } + if (dateType === 'month') { + return ; + } + if (dateType === 'season-year') { + return ; + } + return ; + }; + + const validateContextInput = (value: string): boolean => { + if (value.length > maxContextLength) { + setContextError(`Контекст не може бути довшим за ${maxContextLength} символів`); + return false; + } + if (/[0-9]/.test(value)) { + setContextError('Контекст не може містити цифри'); + return false; + } + if (/[^a-zA-Zа-яА-ЯіІїЇєЄґҐ\s\-']/.test(value)) { + setContextError('Контекст не може містити спеціальні символи'); + return false; + } + if (contexts.some((c) => c.title.toLowerCase() === value.toLowerCase())) { + setContextError('Такий контекст вже існує'); + return false; + } + setContextError(''); + return true; + }; + + const handleContextSearch = (value: string) => { + setContextInputValue(value); + if (value) { + validateContextInput(value); + } else { + setContextError(''); + } + }; + + const handleContextSelect = (value: number | string) => { + if (typeof value === 'string') { + // New context input + if (validateContextInput(value)) { + const newId = Math.min(...contexts.map((c) => c.id), 0) - 1; + const newContext: HistoricalContext = { id: newId, title: value.trim() }; + setContexts([...contexts, newContext]); + setSelectedContexts([...selectedContexts, newId]); + setContextInputValue(''); + setContextError(''); + form.setFieldsValue({ historicalContexts: [...selectedContexts, newId] }); + } + } else { + setSelectedContexts([...selectedContexts, value]); + } + }; + + const handleContextDeselect = (value: number) => { + setSelectedContexts(selectedContexts.filter((id) => id !== value)); + }; + + return ( + } + > +
+

{timelineItem ? 'Редагувати подію' : 'Додати нову подію'}

+
+ Назва *} + name="title" + rules={[ + { required: true, message: 'Назва обов\'язкова' }, + { max: maxTitleLength, message: `Максимум ${maxTitleLength} символів` }, + { whitespace: true, message: 'Назва не може бути порожньою' }, + ]} + > + setTitleCount(e.target.value.length)} + /> + +
{titleCount} / {maxTitleLength}
+ + + + + {getDatePicker()} + + + + + + + {contextError &&
{contextError}
} + + Опис *} + name="description" + rules={[ + { required: true, message: 'Опис обов\'язковий' }, + { max: maxDescriptionLength, message: `Максимум ${maxDescriptionLength} символів` }, + { whitespace: true, message: 'Опис не може бути порожнім' }, + ]} + > +