mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: add media upload to news editor with drag-drop, paste, and file picker
Upload images/videos directly to server from editor. DOMPurify video sanitization, XSS protection, upload cancellation on unmount.
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
|||||||
NewsListResponse,
|
NewsListResponse,
|
||||||
NewsCreateRequest,
|
NewsCreateRequest,
|
||||||
NewsUpdateRequest,
|
NewsUpdateRequest,
|
||||||
|
NewsMediaUploadResponse,
|
||||||
} from '../types/news';
|
} from '../types/news';
|
||||||
|
|
||||||
export const newsApi = {
|
export const newsApi = {
|
||||||
@@ -56,4 +57,19 @@ export const newsApi = {
|
|||||||
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/feature`);
|
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/feature`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadMedia: async (file: File): Promise<NewsMediaUploadResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await apiClient.post<NewsMediaUploadResponse>(
|
||||||
|
'/cabinet/admin/news/media/upload',
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMedia: async (filename: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/cabinet/admin/news/media/${encodeURIComponent(filename)}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4509,6 +4509,11 @@
|
|||||||
"saved": "Article saved",
|
"saved": "Article saved",
|
||||||
"deleted": "Article deleted",
|
"deleted": "Article deleted",
|
||||||
"selectColor": "Select color {{color}}",
|
"selectColor": "Select color {{color}}",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"dropMedia": "Drop image or video here",
|
||||||
|
"uploadError": "Upload failed",
|
||||||
|
"fileTooLarge": "File is too large",
|
||||||
|
"uploadFeaturedImage": "Upload image",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"bold": "Bold",
|
"bold": "Bold",
|
||||||
"italic": "Italic",
|
"italic": "Italic",
|
||||||
|
|||||||
@@ -3957,6 +3957,11 @@
|
|||||||
"saved": "مقاله ذخیره شد",
|
"saved": "مقاله ذخیره شد",
|
||||||
"deleted": "مقاله حذف شد",
|
"deleted": "مقاله حذف شد",
|
||||||
"selectColor": "انتخاب رنگ {{color}}",
|
"selectColor": "انتخاب رنگ {{color}}",
|
||||||
|
"uploading": "در حال آپلود...",
|
||||||
|
"dropMedia": "تصویر یا ویدیو را اینجا رها کنید",
|
||||||
|
"uploadError": "آپلود ناموفق بود",
|
||||||
|
"fileTooLarge": "فایل خیلی بزرگ است",
|
||||||
|
"uploadFeaturedImage": "آپلود تصویر",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"bold": "پررنگ",
|
"bold": "پررنگ",
|
||||||
"italic": "کج",
|
"italic": "کج",
|
||||||
|
|||||||
@@ -5076,6 +5076,11 @@
|
|||||||
"saved": "Новость сохранена",
|
"saved": "Новость сохранена",
|
||||||
"deleted": "Новость удалена",
|
"deleted": "Новость удалена",
|
||||||
"selectColor": "Выбрать цвет {{color}}",
|
"selectColor": "Выбрать цвет {{color}}",
|
||||||
|
"uploading": "Загрузка...",
|
||||||
|
"dropMedia": "Перетащите изображение или видео сюда",
|
||||||
|
"uploadError": "Ошибка загрузки",
|
||||||
|
"fileTooLarge": "Файл слишком большой",
|
||||||
|
"uploadFeaturedImage": "Загрузить изображение",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"bold": "Жирный",
|
"bold": "Жирный",
|
||||||
"italic": "Курсив",
|
"italic": "Курсив",
|
||||||
|
|||||||
@@ -3956,6 +3956,11 @@
|
|||||||
"saved": "文章已保存",
|
"saved": "文章已保存",
|
||||||
"deleted": "文章已删除",
|
"deleted": "文章已删除",
|
||||||
"selectColor": "选择颜色 {{color}}",
|
"selectColor": "选择颜色 {{color}}",
|
||||||
|
"uploading": "上传中...",
|
||||||
|
"dropMedia": "将图片或视频拖到这里",
|
||||||
|
"uploadError": "上传失败",
|
||||||
|
"fileTooLarge": "文件太大",
|
||||||
|
"uploadFeaturedImage": "上传图片",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"bold": "粗体",
|
"bold": "粗体",
|
||||||
"italic": "斜体",
|
"italic": "斜体",
|
||||||
|
|||||||
@@ -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 { useNavigate, useParams } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -110,24 +110,37 @@ const HighlightIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const UploadIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
// --- Toolbar Button ---
|
// --- Toolbar Button ---
|
||||||
interface ToolbarButtonProps {
|
interface ToolbarButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) {
|
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-[44px] min-w-[44px] rounded p-2.5 transition-colors',
|
'min-h-[44px] min-w-[44px] rounded p-2.5 transition-colors',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent-500/20 text-accent-400'
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
: 'text-dark-400 hover:bg-dark-700 hover:text-dark-200',
|
: 'text-dark-400 hover:bg-dark-700 hover:text-dark-200',
|
||||||
@@ -195,6 +208,19 @@ export default function AdminNewsCreate() {
|
|||||||
const [isFeatured, setIsFeatured] = useState(false);
|
const [isFeatured, setIsFeatured] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Media upload state
|
||||||
|
const mediaInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const featuredImageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploadCount, setUploadCount] = useState(0);
|
||||||
|
const isUploading = uploadCount > 0;
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isFeaturedImageUploading, setIsFeaturedImageUploading] = useState(false);
|
||||||
|
const uploadAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Ref to hold the media upload handler — allows editorProps.handlePaste to
|
||||||
|
// reference it without a circular dependency with useEditor.
|
||||||
|
const handleMediaUploadRef = useRef<(file: File) => void>(() => {});
|
||||||
|
|
||||||
// TipTap editor — extensions are memoized with NO deps so the editor is
|
// TipTap editor — extensions are memoized with NO deps so the editor is
|
||||||
// never destroyed/recreated on re-renders. The PlaceholderExtension
|
// never destroyed/recreated on re-renders. The PlaceholderExtension
|
||||||
// placeholder reads the translation at mount time only, which is acceptable
|
// placeholder reads the translation at mount time only, which is acceptable
|
||||||
@@ -231,9 +257,159 @@ export default function AdminNewsCreate() {
|
|||||||
attributes: {
|
attributes: {
|
||||||
class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none',
|
class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none',
|
||||||
},
|
},
|
||||||
|
handlePaste: (_view, event) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return false;
|
||||||
|
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/') || item.type.startsWith('video/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
handleMediaUploadRef.current(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
handleDrop: (_view, event) => {
|
||||||
|
const file = event.dataTransfer?.files[0];
|
||||||
|
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMediaUploadRef.current(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Media upload handlers ---
|
||||||
|
|
||||||
|
const handleMediaUpload = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
if (!isImage && !isVideo) return;
|
||||||
|
|
||||||
|
const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.fileTooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
uploadAbortRef.current = controller;
|
||||||
|
|
||||||
|
setUploadCount((c) => c + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await newsApi.uploadMedia(file);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
if (!isSafeUrl(result.url)) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.media_type === 'image') {
|
||||||
|
editor.chain().focus().setImage({ src: result.url, alt: file.name }).run();
|
||||||
|
} else {
|
||||||
|
const safeUrl = result.url.replace(/"/g, '"');
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent(
|
||||||
|
`<video src="${safeUrl}" controls class="w-full rounded-xl max-h-96"></video>`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
haptic.success();
|
||||||
|
} catch {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setUploadCount((c) => c - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, haptic, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the ref in sync so editorProps handlers can access the latest callback
|
||||||
|
handleMediaUploadRef.current = handleMediaUpload;
|
||||||
|
|
||||||
|
// Cancel in-flight uploads on unmount to prevent state updates on destroyed editor
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
uploadAbortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleMediaUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
[handleMediaUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFeaturedImageUpload = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) return;
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.fileTooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFeaturedImageUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await newsApi.uploadMedia(file);
|
||||||
|
setFeaturedImageUrl(result.url);
|
||||||
|
haptic.success();
|
||||||
|
} catch {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
} finally {
|
||||||
|
setIsFeaturedImageUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[haptic, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditorDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleMediaUpload(file);
|
||||||
|
},
|
||||||
|
[handleMediaUpload],
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch existing categories for suggestions
|
// Fetch existing categories for suggestions
|
||||||
const { data: newsData } = useQuery({
|
const { data: newsData } = useQuery({
|
||||||
queryKey: ['admin', 'news', 'categories'],
|
queryKey: ['admin', 'news', 'categories'],
|
||||||
@@ -328,19 +504,6 @@ export default function AdminNewsCreate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Toolbar actions
|
// Toolbar actions
|
||||||
const addImage = () => {
|
|
||||||
const url = window.prompt(t('news.admin.toolbar.imageUrlPrompt'));
|
|
||||||
if (url && editor) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.chain().focus().setImage({ src: url }).run();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addLink = () => {
|
const addLink = () => {
|
||||||
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
|
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
|
||||||
if (url && editor) {
|
if (url && editor) {
|
||||||
@@ -500,13 +663,37 @@ export default function AdminNewsCreate() {
|
|||||||
{/* Featured Image URL */}
|
{/* Featured Image URL */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t('news.admin.imageLabel')}</label>
|
<label className="label">{t('news.admin.imageLabel')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={featuredImageUrl}
|
value={featuredImageUrl}
|
||||||
onChange={(e) => setFeaturedImageUrl(e.target.value)}
|
onChange={(e) => setFeaturedImageUrl(e.target.value)}
|
||||||
className="input"
|
className="input flex-1"
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => featuredImageInputRef.current?.click()}
|
||||||
|
disabled={isFeaturedImageUploading}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-dark-700 px-4 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label={t('news.admin.uploadFeaturedImage')}
|
||||||
|
>
|
||||||
|
{isFeaturedImageUploading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<UploadIcon />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{t('news.admin.uploadFeaturedImage')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={featuredImageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
onChange={handleFeaturedImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{isSafeUrl(featuredImageUrl) && (
|
{isSafeUrl(featuredImageUrl) && (
|
||||||
<div className="mt-2 overflow-hidden rounded-xl">
|
<div className="mt-2 overflow-hidden rounded-xl">
|
||||||
<img
|
<img
|
||||||
@@ -542,7 +729,33 @@ export default function AdminNewsCreate() {
|
|||||||
{/* Content editor */}
|
{/* Content editor */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t('news.admin.contentLabel')}</label>
|
<label className="label">{t('news.admin.contentLabel')}</label>
|
||||||
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50">
|
<div
|
||||||
|
className="relative overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50"
|
||||||
|
onDragOver={handleEditorDragOver}
|
||||||
|
onDragLeave={handleEditorDragLeave}
|
||||||
|
onDrop={handleEditorDrop}
|
||||||
|
>
|
||||||
|
{/* Upload progress overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-dark-900/60 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('news.admin.uploading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{isDragging && !isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-accent-400 bg-accent-400/10">
|
||||||
|
<span className="text-sm font-semibold text-accent-400">
|
||||||
|
{t('news.admin.dropMedia')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
{editor && (
|
{editor && (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 p-2">
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 p-2">
|
||||||
@@ -659,8 +872,16 @@ export default function AdminNewsCreate() {
|
|||||||
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton onClick={addImage} title={t('news.admin.toolbar.image')}>
|
<ToolbarButton
|
||||||
|
onClick={() => mediaInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
title={t('news.admin.toolbar.image')}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
<ImageIcon />
|
<ImageIcon />
|
||||||
|
)}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -668,6 +889,16 @@ export default function AdminNewsCreate() {
|
|||||||
{/* Editor content */}
|
{/* Editor content */}
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file inputs */}
|
||||||
|
<input
|
||||||
|
ref={mediaInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,video/mp4,video/webm"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error feedback */}
|
{/* Error feedback */}
|
||||||
|
|||||||
@@ -99,6 +99,26 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hook: validate <video> src — only allow http/https URLs
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Force safe defaults
|
||||||
|
node.setAttribute('controls', '');
|
||||||
|
node.setAttribute('preload', 'metadata');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strict allowlist of tags and attributes for article content.
|
* Strict allowlist of tags and attributes for article content.
|
||||||
* Using ALLOWED_TAGS / ALLOWED_ATTR instead of ADD_TAGS / ADD_ATTR
|
* Using ALLOWED_TAGS / ALLOWED_ATTR instead of ADD_TAGS / ADD_ATTR
|
||||||
@@ -145,6 +165,7 @@ const SANITIZE_CONFIG = {
|
|||||||
'sup',
|
'sup',
|
||||||
'small',
|
'small',
|
||||||
'img',
|
'img',
|
||||||
|
'video',
|
||||||
// Embed (validated by afterSanitizeAttributes hook)
|
// Embed (validated by afterSanitizeAttributes hook)
|
||||||
'iframe',
|
'iframe',
|
||||||
'figure',
|
'figure',
|
||||||
@@ -164,6 +185,9 @@ const SANITIZE_CONFIG = {
|
|||||||
'start',
|
'start',
|
||||||
'reversed',
|
'reversed',
|
||||||
'type',
|
'type',
|
||||||
|
// video-specific
|
||||||
|
'controls',
|
||||||
|
'preload',
|
||||||
// iframe-specific (validated by hook)
|
// iframe-specific (validated by hook)
|
||||||
'frameborder',
|
'frameborder',
|
||||||
'allowfullscreen',
|
'allowfullscreen',
|
||||||
|
|||||||
@@ -57,3 +57,13 @@ export interface NewsCreateRequest {
|
|||||||
export interface NewsUpdateRequest extends Partial<NewsCreateRequest> {
|
export interface NewsUpdateRequest extends Partial<NewsCreateRequest> {
|
||||||
id?: never;
|
id?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewsMediaUploadResponse {
|
||||||
|
url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
media_type: 'image' | 'video';
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user