diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index ef96dee..0a245d7 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -60,10 +60,11 @@ "confirmNo": "No", "publishTitle": "Confirmation", "publishNow": "Publish now", - "saveAsDraft": "Save as draft", + "saveAsDraft": "Publish later", "confirmPublish": "Are you sure you want to publish this news?", - "confirmDraft": "Are you sure you want to save this news as a draft?", + "confirmDraft": "Are you sure you want to save this news as a draft? It will be saved in the dashboard and you can publish it later.", "createFailed": "Failed to create news. Please try again.", + "publishedSuccessfully": "News successfully published!", "createdAndPublished": "News successfully created and published!", "savedAsDraft": "News successfully saved as draft!", "fileUploadFailed": "File upload failed. Please try again." @@ -73,7 +74,13 @@ "title": "Edit News", "saveButton": "Save Changes", "confirmUpdate": "Are you sure you want to save changes to this news?", - "updatedSuccessfully": "News successfully updated!" + "updatedSuccessfully": "News successfully updated!", + "updatedAndPublishedSuccessfully": "News successfully updated and published!", + "publishedSuccessfully": "News successfully published!", + "updateAndPublish": "Update and Publish", + "updateAndSaveDraft": "Save Draft", + "confirmUpdateAndPublish": "Are you sure you want to update and publish this news?", + "confirmUpdateAndDraft": "Are you sure you want to update and save this news as a draft? It will be saved in the dashboard and you can publish it later." }, "news-delete": { "title": "Delete News", diff --git a/public/i18n/uk/admin.json b/public/i18n/uk/admin.json index 2d02609..488a241 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -60,10 +60,11 @@ "confirmNo": "Ні", "publishTitle": "Підтвердження", "publishNow": "Опублікувати зараз", - "saveAsDraft": "Зберегти як чернетку", + "saveAsDraft": "Опублікувати пізніше", "confirmPublish": "Ви впевнені, що хочете опублікувати цю новину?", - "confirmDraft": "Ви впевнені, що хочете зберегти цю новину як чернетку?", + "confirmDraft": "Ви впевнені, що хочете зберегти цю новину як чернетку? Вона буде збережена у кабінеті і ви зможете опублікувати її пізніше.", "createFailed": "Не вдалося створити новину. Будь ласка, спробуйте ще раз.", + "publishedSuccessfully": "Новина успішно опублікована!", "createdAndPublished": "Новина успішно створена та опублікована!", "savedAsDraft": "Новина успішно збережена як чернетка!", "fileUploadFailed": "Не вдалося завантажити файл. Будь ласка, спробуйте ще раз." @@ -73,7 +74,12 @@ "title": "Редагувати новину", "saveButton": "Зберегти зміни", "confirmUpdate": "Ви впевнені, що хочете зберегти зміни в цій новині?", - "updatedSuccessfully": "Новина успішно оновлена!" + "updatedSuccessfully": "Новина успішно оновлена!", + "updatedAndPublishedSuccessfully": "Новина успішно оновлена та опублікована!", + "updateAndPublish": "Оновити та опублікувати", + "updateAndSaveDraft": "Зберегти чернетку", + "confirmUpdateAndPublish": "Ви впевнені, що хочете оновити та опублікувати цю новину?", + "confirmUpdateAndDraft": "Ви впевнені, що хочете оновити та зберегти цю новину як чернетку? Вона буде збережена у кабінеті і ви зможете опублікувати її пізніше." }, "news-delete": { "title": "Видалити новину", diff --git a/src/pages/admin/News/NewsAdmin.module.scss b/src/pages/admin/News/NewsAdmin.module.scss index 0ea5600..a8b1172 100644 --- a/src/pages/admin/News/NewsAdmin.module.scss +++ b/src/pages/admin/News/NewsAdmin.module.scss @@ -85,6 +85,15 @@ } } + .publish { + color: #6b7280; + + &:hover { + color: #15803d; + background: #dcfce7; + } + } + .delete { color: #6b7280; diff --git a/src/pages/admin/News/NewsAdminList.tsx b/src/pages/admin/News/NewsAdminList.tsx index 55538e1..3d63d53 100644 --- a/src/pages/admin/News/NewsAdminList.tsx +++ b/src/pages/admin/News/NewsAdminList.tsx @@ -21,6 +21,7 @@ export default function NewsAdminList() { const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [statuses, setStatuses] = useState([]); + const [reloadVersion, setReloadVersion] = useState(0); const loadNews = useCallback(async (signal?: AbortSignal) => { setLoading(true); @@ -45,7 +46,7 @@ export default function NewsAdminList() { loadNews(controller.signal); return () => controller.abort(); - }, [loadNews]); + }, [loadNews, reloadVersion]); const handleDeleted = (id: number) => { setNews(prev => prev.filter(n => n.id !== id)); @@ -59,6 +60,11 @@ export default function NewsAdminList() { setPage(0); }; + const handlePublished = () => { + setPage(0); + setReloadVersion(version => version + 1); + }; + const renderContent = () => { if (loading) { return

{t('news.loading')}

; @@ -80,7 +86,7 @@ export default function NewsAdminList() { {news.map(item => ( - + ))} diff --git a/src/pages/admin/News/NewsAdminRow.tsx b/src/pages/admin/News/NewsAdminRow.tsx index ed6156b..dc8617a 100644 --- a/src/pages/admin/News/NewsAdminRow.tsx +++ b/src/pages/admin/News/NewsAdminRow.tsx @@ -1,7 +1,7 @@ -import { Modal, ModalDialog, ModalClose, DialogTitle, DialogContent, DialogActions } from '@mui/joy'; import { newsService } from '@services/newsService'; +import { ConfirmModal } from '@shared/components/ConfirmModal'; import type { NewsAdminItem, NewsStatus } from '@shared/models/news'; -import { SquarePen, Trash2 } from 'lucide-react'; +import { SquarePen, Trash2, Rocket } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -12,13 +12,41 @@ import styles from './NewsAdmin.module.scss'; type NewsAdminRowProps = { readonly news: NewsAdminItem; readonly onDeleted: (id: number) => void; + readonly onPublished: () => void; }; -export default function NewsAdminRow({ news, onDeleted }: NewsAdminRowProps) { +export default function NewsAdminRow({ news, onDeleted, onPublished }: NewsAdminRowProps) { const { t } = useTranslation('admin'); const navigate = useNavigate(); const [deleteOpen, setDeleteOpen] = useState(false); + const [publishOpen, setPublishOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + + const handlePublishConfirm = async () => { + if (isPublishing) return; + + setIsPublishing(true); + try { + const fullNews = await newsService.getNewsById(news.id); + const { data: files } = await newsService.getFilesByNewsId(news.id); + await newsService.updateNews({ + id: news.id, + title: fullNews.title, + content: fullNews.content, + publishNow: true, + fileIds: files.map(f => f.id) + }); + toast.success(t('news-create.publishedSuccessfully')); + setPublishOpen(false); + onPublished(); + } catch { + toast.error(t('news-create.createFailed')); + setPublishOpen(false); + } finally { + setIsPublishing(false); + } + }; const statusBadgeClass: Record = { DRAFT: styles.badgeDraft, @@ -42,6 +70,22 @@ export default function NewsAdminRow({ news, onDeleted }: NewsAdminRowProps) {
+ + - - - - + setDeleteOpen(false)} + title={t('news-delete.title')} + message={t('news-delete.confirmDelete')} + isLoading={isDeleting} + onConfirm={() => { + if (isDeleting) return; + setIsDeleting(true); + newsService.deleteNews(news.id) + .then(() => { + setDeleteOpen(false); + onDeleted(news.id); + toast.success(t('news-delete.deletedSuccessfully')); + }) + .catch(() => { + toast.error(t('news-delete.deletedFailed')); + setDeleteOpen(false); + }) + .finally(() => setIsDeleting(false)); + }} + /> + + setPublishOpen(false)} + title={t('news-create.publishTitle')} + message={t('news-create.confirmPublish')} + isLoading={isPublishing} + onConfirm={handlePublishConfirm} + /> ); } \ No newline at end of file diff --git a/src/pages/admin/News/NewsForm.tsx b/src/pages/admin/News/NewsForm.tsx index 47143e5..5b24b0a 100644 --- a/src/pages/admin/News/NewsForm.tsx +++ b/src/pages/admin/News/NewsForm.tsx @@ -1,14 +1,7 @@ import { BackButton } from '@components/BackButton'; import Editor, { type EditorHandle } from '@components/Editor'; -import { - Modal, - ModalDialog, - DialogTitle, - DialogContent, - DialogActions, - ModalClose, -} from '@mui/joy'; import { newsService } from '@services/newsService'; +import { ConfirmModal } from '@shared/components/ConfirmModal'; import type { NewsDto } from '@shared/models/news'; import React, { useState, useRef, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -73,7 +66,7 @@ const NewsForm: React.FC = () => { id: Number(id), title: data.title, content: data.content, - publishNow, + publishNow: data.publishNow, fileIds, removedFileIds, }); @@ -81,23 +74,38 @@ const NewsForm: React.FC = () => { await newsService.createNews({ title: data.title, content: data.content, - publishNow, + publishNow: data.publishNow, fileIds, }); } - navigate(publishNow ? '/profile/news' : '/profile/drafts'); + navigate('/profile/news'); }; - const onSubmit = (data: NewsDto) => { + const onSubmit = (data: NewsDto, e?: React.BaseSyntheticEvent) => { (document.activeElement as HTMLElement)?.blur(); - setPendingData({ ...data, publishNow, }); + + const submitterName = (e?.nativeEvent as SubmitEvent)?.submitter?.getAttribute('name'); + let finalPublishNow = publishNow; + + if (submitterName === 'actionPublish') { + finalPublishNow = true; + } else if (submitterName === 'actionDraft') { + finalPublishNow = false; + } + + setPendingData({ ...data, publishNow: finalPublishNow }); setOpen(true); }; - const getSuccessMessage = (isEdit: boolean, publishNow: boolean) => { - if (isEdit) return t('news-edit.updatedSuccessfully'); - if (publishNow) return t('news-create.createdAndPublished'); + const getSuccessMessage = (isEdit: boolean, isPublishingNow: boolean) => { + if (isEdit) { + if (!publishNow && isPublishingNow) { + return t('news-edit.updatedAndPublishedSuccessfully'); + } + return t('news-edit.updatedSuccessfully'); + } + if (isPublishingNow) return t('news-create.createdAndPublished'); return t('news-create.savedAsDraft'); }; @@ -129,11 +137,58 @@ const NewsForm: React.FC = () => { }; const getDialogMessage = () => { - if (isEditMode) return t('news-edit.confirmUpdate'); + if (isEditMode) { + if (publishNow) { + return t('news-edit.confirmUpdate'); + } else { + return pendingData?.publishNow + ? t('news-edit.confirmUpdateAndPublish') + : t('news-edit.confirmUpdateAndDraft'); + } + } if (pendingData?.publishNow) return t('news-create.confirmPublish'); return t('news-create.confirmDraft'); }; + const renderActionButtons = () => { + if (!isEditMode) { + return ( + + ); + } + + if (publishNow) { + return ( + + ); + } + + return ( +
+ + +
+ ); + }; + return (
{
)} - + {renderActionButtons()} - setOpen(false)}> - - - {t('news-create.publishTitle')} - - {getDialogMessage()} - - - - - - - +
); }; diff --git a/src/shared/components/ConfirmModal.tsx b/src/shared/components/ConfirmModal.tsx new file mode 100644 index 0000000..a7ca275 --- /dev/null +++ b/src/shared/components/ConfirmModal.tsx @@ -0,0 +1,55 @@ +import { Modal, ModalDialog, ModalClose, DialogTitle, DialogContent, DialogActions } from '@mui/joy'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface ConfirmModalProps { + open: boolean; + onClose: () => void; + title: string; + message: string; + onConfirm: () => void; + confirmText?: string; + cancelText?: string; + isLoading?: boolean; +} + +export const ConfirmModal: React.FC = ({ + open, + onClose, + title, + message, + onConfirm, + confirmText, + cancelText, + isLoading = false, +}) => { + const { t } = useTranslation('admin'); + + return ( + !isLoading && onClose()}> + + + {title} + {message} + + + + + + + ); +}; diff --git a/src/shared/services/newsService.ts b/src/shared/services/newsService.ts index 2551613..152e8f1 100644 --- a/src/shared/services/newsService.ts +++ b/src/shared/services/newsService.ts @@ -1,6 +1,5 @@ import { axiosInstance } from '@shared/api/axiosInstance'; import type { CreateNewsRequest, UpdateNewsRequest, NewsDetailItem, FileDto, NewsAdminResponse, NewsStatus } from '@shared/models/news'; -import axios from 'axios'; const API_BASE = import.meta.env.VITE_API_URL; @@ -9,11 +8,11 @@ export const newsService = { updateNews: (data: UpdateNewsRequest) => axiosInstance.put(`${API_BASE}/api/v1/news`, data), deleteNews: (id: number) => axiosInstance.delete(`${API_BASE}/api/v1/news/${id}`), getNewsById: async (id: number) => { - const { data } = await axios.get(`${API_BASE}/api/v1/news/${id}`); + const { data } = await axiosInstance.get(`${API_BASE}/api/v1/news/${id}`); return data; }, getFilesByNewsId: async (newsId: number) => { - return axios.get(`${API_BASE}/api/v1/files`, { + return axiosInstance.get(`${API_BASE}/api/v1/files`, { params: { entityType: 'NEWS', entityId: newsId } }); },