diff --git a/src/api/news.ts b/src/api/news.ts index 00b2503..bcad9d3 100644 --- a/src/api/news.ts +++ b/src/api/news.ts @@ -4,6 +4,7 @@ import type { NewsListResponse, NewsCreateRequest, NewsUpdateRequest, + NewsMediaUploadResponse, } from '../types/news'; export const newsApi = { @@ -56,4 +57,19 @@ export const newsApi = { const response = await apiClient.post(`/cabinet/admin/news/${id}/feature`); return response.data; }, + + uploadMedia: async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post( + '/cabinet/admin/news/media/upload', + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); + return response.data; + }, + + deleteMedia: async (filename: string): Promise => { + await apiClient.delete(`/cabinet/admin/news/media/${encodeURIComponent(filename)}`); + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index c9f4647..4b6a51b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4509,6 +4509,11 @@ "saved": "Article saved", "deleted": "Article deleted", "selectColor": "Select color {{color}}", + "uploading": "Uploading...", + "dropMedia": "Drop image or video here", + "uploadError": "Upload failed", + "fileTooLarge": "File is too large", + "uploadFeaturedImage": "Upload image", "toolbar": { "bold": "Bold", "italic": "Italic", diff --git a/src/locales/fa.json b/src/locales/fa.json index 1ad952b..49a1740 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3957,6 +3957,11 @@ "saved": "مقاله ذخیره شد", "deleted": "مقاله حذف شد", "selectColor": "انتخاب رنگ {{color}}", + "uploading": "در حال آپلود...", + "dropMedia": "تصویر یا ویدیو را اینجا رها کنید", + "uploadError": "آپلود ناموفق بود", + "fileTooLarge": "فایل خیلی بزرگ است", + "uploadFeaturedImage": "آپلود تصویر", "toolbar": { "bold": "پررنگ", "italic": "کج", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3ecaa27..a05ac9d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5076,6 +5076,11 @@ "saved": "Новость сохранена", "deleted": "Новость удалена", "selectColor": "Выбрать цвет {{color}}", + "uploading": "Загрузка...", + "dropMedia": "Перетащите изображение или видео сюда", + "uploadError": "Ошибка загрузки", + "fileTooLarge": "Файл слишком большой", + "uploadFeaturedImage": "Загрузить изображение", "toolbar": { "bold": "Жирный", "italic": "Курсив", diff --git a/src/locales/zh.json b/src/locales/zh.json index 194ca53..9dc56c7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3956,6 +3956,11 @@ "saved": "文章已保存", "deleted": "文章已删除", "selectColor": "选择颜色 {{color}}", + "uploading": "上传中...", + "dropMedia": "将图片或视频拖到这里", + "uploadError": "上传失败", + "fileTooLarge": "文件太大", + "uploadFeaturedImage": "上传图片", "toolbar": { "bold": "粗体", "italic": "斜体", diff --git a/src/pages/AdminNewsCreate.tsx b/src/pages/AdminNewsCreate.tsx index 2a465b7..e1cafdc 100644 --- a/src/pages/AdminNewsCreate.tsx +++ b/src/pages/AdminNewsCreate.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -110,24 +110,37 @@ const HighlightIcon = () => ( ); +const UploadIcon = () => ( + + + +); + // --- Toolbar Button --- interface ToolbarButtonProps { onClick: () => void; isActive?: boolean; + disabled?: boolean; title: string; children: React.ReactNode; } -function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) { +function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) { return ( + setFeaturedImageUrl(e.target.value)} - className="input" - placeholder="https://..." + ref={featuredImageInputRef} + type="file" + accept="image/jpeg,image/png,image/webp,image/gif" + onChange={handleFeaturedImageUpload} + className="hidden" + aria-hidden="true" /> {isSafeUrl(featuredImageUrl) && (
@@ -542,7 +729,33 @@ export default function AdminNewsCreate() { {/* Content editor */}
-
+
+ {/* Upload progress overlay */} + {isUploading && ( +
+
+
+ + {t('news.admin.uploading')} + +
+
+ )} + + {/* Drag overlay */} + {isDragging && !isUploading && ( +
+ + {t('news.admin.dropMedia')} + +
+ )} + {/* Toolbar */} {editor && (
@@ -659,8 +872,16 @@ export default function AdminNewsCreate() { - - + mediaInputRef.current?.click()} + disabled={isUploading} + title={t('news.admin.toolbar.image')} + > + {isUploading ? ( +
+ ) : ( + + )}
)} @@ -668,6 +889,16 @@ export default function AdminNewsCreate() { {/* Editor content */}
+ + {/* Hidden file inputs */} +
{/* Error feedback */} diff --git a/src/pages/NewsArticle.tsx b/src/pages/NewsArticle.tsx index 9ddcee9..1098fc9 100644 --- a/src/pages/NewsArticle.tsx +++ b/src/pages/NewsArticle.tsx @@ -99,6 +99,26 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => { } }); +// Hook: validate