import { useState, useEffect, useMemo, useRef } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import ImageExtension from '@tiptap/extension-image'; import LinkExtension from '@tiptap/extension-link'; import PlaceholderExtension from '@tiptap/extension-placeholder'; import TextAlignExtension from '@tiptap/extension-text-align'; import UnderlineExtension from '@tiptap/extension-underline'; import HighlightExtension from '@tiptap/extension-highlight'; import { newsApi } from '../api/news'; import { AdminBackButton } from '../components/admin'; import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { cn } from '../lib/utils'; import type { NewsCreateRequest } from '../types/news'; // --- Icons --- const BoldIcon = () => ( ); const ItalicIcon = () => ( ); const UnderlineIcon = () => ( ); const StrikeIcon = () => ( ); const H1Icon = () => H1; const H2Icon = () => H2; const H3Icon = () => H3; const ListBulletIcon = () => ( ); const ListOrderedIcon = () => ( ); const QuoteIcon = () => ( ); const CodeBlockIcon = () => ( ); const ImageIcon = () => ( ); const LinkIcon = () => ( ); const AlignLeftIcon = () => ( ); const AlignCenterIcon = () => ( ); const HighlightIcon = () => ( ); // --- Toolbar Button --- interface ToolbarButtonProps { onClick: () => void; isActive?: boolean; title: string; children: React.ReactNode; } function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) { return ( ); } // --- Security: URL scheme validation --- function isSafeUrl(url: string | null | undefined): boolean { if (!url) return false; try { const parsed = new URL(url); return parsed.protocol === 'https:' || parsed.protocol === 'http:'; } catch { return false; } } // --- Slug utility --- function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^\w\s\-а-яёА-ЯЁ]/g, '') .replace(/[\s_]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 100); } // --- Predefined category colors --- const CATEGORY_COLORS = [ '#00e5a0', '#00b4d8', '#f72585', '#ffd60a', '#7c3aed', '#f97316', '#06b6d4', '#ec4899', ]; export default function AdminNewsCreate() { const { t } = useTranslation(); const navigate = useNavigate(); const { id: rawId } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const haptic = useHapticFeedback(); const articleId = rawId != null ? Number(rawId) : undefined; const isEdit = articleId != null && !Number.isNaN(articleId); // Form state const [title, setTitle] = useState(''); const [slug, setSlug] = useState(''); const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); const [category, setCategory] = useState(''); const [categoryColor, setCategoryColor] = useState('#00e5a0'); const [tag, setTag] = useState(''); const [excerpt, setExcerpt] = useState(''); const [featuredImageUrl, setFeaturedImageUrl] = useState(''); const [readTimeMinutes, setReadTimeMinutes] = useState(3); const [isPublished, setIsPublished] = useState(false); const [isFeatured, setIsFeatured] = useState(false); const [saveError, setSaveError] = useState(null); // 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 // since locale changes at runtime are rare and the editor retains content. // Using [t] as the dependency would destroy the editor on every locale change. const extensions = useMemo( () => [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), UnderlineExtension, LinkExtension.configure({ openOnClick: false, HTMLAttributes: { class: 'link' }, }), ImageExtension.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full' }, }), PlaceholderExtension.configure({ placeholder: t('news.admin.contentLabel'), }), TextAlignExtension.configure({ types: ['heading', 'paragraph'], }), HighlightExtension, ], // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const editor = useEditor({ extensions, editorProps: { attributes: { class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none', }, }, }); // Fetch existing categories for suggestions const { data: newsData } = useQuery({ queryKey: ['admin', 'news', 'categories'], queryFn: () => newsApi.getAdminNews({ limit: 1 }), staleTime: 60_000, }); const existingCategories = newsData?.categories ?? []; // Fetch article for editing const { data: articleData, isLoading: isLoadingArticle } = useQuery({ queryKey: ['admin', 'news', 'article', articleId], queryFn: () => { if (articleId == null) throw new Error('Missing article id parameter'); return newsApi.getAdminArticle(articleId); }, enabled: isEdit, staleTime: 0, gcTime: 0, refetchOnMount: true, refetchOnWindowFocus: false, }); // Populate form when article data loads — guard prevents re-populating on editor re-init const editorPopulated = useRef(false); useEffect(() => { if (!articleData) return; setTitle(articleData.title); setSlug(articleData.slug); setSlugManuallyEdited(true); setCategory(articleData.category); setCategoryColor(articleData.category_color); setTag(articleData.tag ?? ''); setExcerpt(articleData.excerpt ?? ''); setFeaturedImageUrl(articleData.featured_image_url ?? ''); setReadTimeMinutes(articleData.read_time_minutes); setIsPublished(articleData.is_published); setIsFeatured(articleData.is_featured); if (editor && articleData.content && !editorPopulated.current) { editor.commands.setContent(articleData.content); editorPopulated.current = true; } }, [articleData, editor]); // Auto-generate slug from title useEffect(() => { if (!slugManuallyEdited && title) { setSlug(generateSlug(title)); } }, [title, slugManuallyEdited]); // Save mutation const saveMutation = useMutation({ mutationFn: (data: NewsCreateRequest) => { if (isEdit && articleId != null) { return newsApi.updateArticle(articleId, data); } return newsApi.createArticle(data); }, onSuccess: () => { haptic.success(); queryClient.invalidateQueries({ queryKey: ['admin', 'news'] }); queryClient.invalidateQueries({ queryKey: ['news'] }); navigate('/admin/news'); }, onError: (error: Error) => { haptic.error(); setSaveError(error.message || t('news.admin.saveError')); }, }); const handleSave = () => { setSaveError(null); if (!title.trim() || !slug.trim() || !category.trim()) return; const content = editor?.getHTML() ?? ''; const data: NewsCreateRequest = { title: title.trim(), slug: slug.trim(), content, excerpt: excerpt.trim() || null, category: category.trim(), category_color: categoryColor, tag: tag.trim() || null, featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null, is_published: isPublished, is_featured: isFeatured, read_time_minutes: readTimeMinutes, }; haptic.buttonPress(); saveMutation.mutate(data); }; // 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) { try { const parsed = new URL(url); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return; } catch { return; } editor.chain().focus().setLink({ href: url }).run(); } }; if (isEdit && isLoadingArticle) { return (
); } return (
{/* Header */}

{isEdit ? t('news.admin.edit') : t('news.admin.create')}

{/* Form */}
{/* Title */}
setTitle(e.target.value)} className="input" required />
{/* Slug */}
{ setSlug(e.target.value); setSlugManuallyEdited(true); }} className="input font-mono text-sm" required />
{/* Category + color row */}
setCategory(e.target.value)} className="input" list="category-suggestions" required /> {existingCategories.map((cat) => (
setCategoryColor(e.target.value)} className="input flex-1 font-mono text-sm" />
{CATEGORY_COLORS.map((color) => (
{/* Tag + Read time row */}
setTag(e.target.value)} className="input font-mono text-sm uppercase" />
setReadTimeMinutes(Number(e.target.value) || 1)} min={1} max={60} className="input" />
{/* Excerpt */}