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:
Fringg
2026-03-23 11:58:18 +03:00
parent 8d994f75d9
commit 723591e5c3
8 changed files with 324 additions and 23 deletions

View File

@@ -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<NewsArticle>(`/cabinet/admin/news/${id}/feature`);
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)}`);
},
};

View File

@@ -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",

View File

@@ -3957,6 +3957,11 @@
"saved": "مقاله ذخیره شد",
"deleted": "مقاله حذف شد",
"selectColor": "انتخاب رنگ {{color}}",
"uploading": "در حال آپلود...",
"dropMedia": "تصویر یا ویدیو را اینجا رها کنید",
"uploadError": "آپلود ناموفق بود",
"fileTooLarge": "فایل خیلی بزرگ است",
"uploadFeaturedImage": "آپلود تصویر",
"toolbar": {
"bold": "پررنگ",
"italic": "کج",

View File

@@ -5076,6 +5076,11 @@
"saved": "Новость сохранена",
"deleted": "Новость удалена",
"selectColor": "Выбрать цвет {{color}}",
"uploading": "Загрузка...",
"dropMedia": "Перетащите изображение или видео сюда",
"uploadError": "Ошибка загрузки",
"fileTooLarge": "Файл слишком большой",
"uploadFeaturedImage": "Загрузить изображение",
"toolbar": {
"bold": "Жирный",
"italic": "Курсив",

View File

@@ -3956,6 +3956,11 @@
"saved": "文章已保存",
"deleted": "文章已删除",
"selectColor": "选择颜色 {{color}}",
"uploading": "上传中...",
"dropMedia": "将图片或视频拖到这里",
"uploadError": "上传失败",
"fileTooLarge": "文件太大",
"uploadFeaturedImage": "上传图片",
"toolbar": {
"bold": "粗体",
"italic": "斜体",

View File

@@ -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 = () => (
</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 ---
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-label={title}
aria-pressed={isActive}
className={cn(
'min-h-[44px] min-w-[44px] rounded p-2.5 transition-colors',
disabled && 'cursor-not-allowed opacity-50',
isActive
? 'bg-accent-500/20 text-accent-400'
: '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 [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
// never destroyed/recreated on re-renders. The PlaceholderExtension
// placeholder reads the translation at mount time only, which is acceptable
@@ -231,9 +257,159 @@ export default function AdminNewsCreate() {
attributes: {
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, '&quot;');
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
const { data: newsData } = useQuery({
queryKey: ['admin', 'news', 'categories'],
@@ -328,19 +504,6 @@ export default function AdminNewsCreate() {
};
// 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 url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
if (url && editor) {
@@ -500,13 +663,37 @@ export default function AdminNewsCreate() {
{/* Featured Image URL */}
<div>
<label className="label">{t('news.admin.imageLabel')}</label>
<div className="flex items-center gap-2">
<input
type="text"
value={featuredImageUrl}
onChange={(e) => setFeaturedImageUrl(e.target.value)}
className="input"
className="input flex-1"
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) && (
<div className="mt-2 overflow-hidden rounded-xl">
<img
@@ -542,7 +729,33 @@ export default function AdminNewsCreate() {
{/* Content editor */}
<div>
<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 */}
{editor && (
<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')}>
<LinkIcon />
</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 />
)}
</ToolbarButton>
</div>
)}
@@ -668,6 +889,16 @@ export default function AdminNewsCreate() {
{/* Editor content */}
<EditorContent editor={editor} />
</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>
{/* Error feedback */}

View File

@@ -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.
* Using ALLOWED_TAGS / ALLOWED_ATTR instead of ADD_TAGS / ADD_ATTR
@@ -145,6 +165,7 @@ const SANITIZE_CONFIG = {
'sup',
'small',
'img',
'video',
// Embed (validated by afterSanitizeAttributes hook)
'iframe',
'figure',
@@ -164,6 +185,9 @@ const SANITIZE_CONFIG = {
'start',
'reversed',
'type',
// video-specific
'controls',
'preload',
// iframe-specific (validated by hook)
'frameborder',
'allowfullscreen',

View File

@@ -57,3 +57,13 @@ export interface NewsCreateRequest {
export interface NewsUpdateRequest extends Partial<NewsCreateRequest> {
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;
}