Files
bedolaga-cabinet/src/pages/AdminInfoPageEditor.tsx
c0mrade d0e0b6b7e3 feat(cabinet): migrate all icons to the panel's Phosphor set
Replace the cabinet's hand-written heroicons-style SVG icon components with
the Remnawave panel's own icon family — Phosphor via react-icons/pi (Duotone).
All icons now live in a single central barrel (src/components/icons:
index + extended-icons + editor-icons, 144 icons); every feature file imports
from it instead of redefining inline SVGs (~440 icon defs removed).

- Add react-icons dependency
- Desktop nav (AppShell) now uses the central Phosphor icons, removing a
  stroke-vs-duotone inconsistency the partial migration introduced
- Icons with custom props (SortIcon's direction, expandable chevrons) kept as
  thin Phosphor wrappers; the RemnawaveIcon brand logo and animated SVGs
  (checkmarks, spinners) are intentionally left as-is
- Restore the original per-call-site icon sizes wherever the centralized
  default differed (211 call sites)
2026-05-31 18:50:50 +03:00

1397 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useNavigate, useParams, useSearchParams } 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 { VideoExtension } from '../lib/tiptap-video';
import { infoPagesApi } from '../api/infoPages';
import { newsApi } from '../api/news';
import { usePrompt } from '../store/promptDialog';
import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { cn } from '../lib/utils';
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikeIcon,
ListBulletIcon,
ListOrderedIcon,
QuoteIcon,
CodeBlockIcon,
ImageIcon,
LinkIcon,
AlignLeftIcon,
AlignCenterIcon,
HighlightIcon,
ChevronUpIcon,
ChevronDownIcon,
TrashSmallIcon,
PlusSmallIcon,
} from '@/components/icons';
import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages';
const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
type LocaleCode = (typeof AVAILABLE_LOCALES)[number];
// --- Icons ---
const H1Icon = () => <span className="text-xs font-bold">H1</span>;
const H2Icon = () => <span className="text-xs font-bold">H2</span>;
const H3Icon = () => <span className="text-xs font-bold">H3</span>;
// --- Toolbar Button ---
interface ToolbarButtonProps {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
title: string;
children: React.ReactNode;
}
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',
)}
>
{children}
</button>
);
}
// --- 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 ---
const TRANSLIT_MAP: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'yo',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'kh',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'shch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
function generateSlug(title: string): string {
const lower = title.toLowerCase();
const transliterated = Array.from(lower)
.map((ch) => TRANSLIT_MAP[ch] ?? ch)
.join('');
return transliterated
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 100);
}
// --- FAQ Answer Rich Editor ---
const FAQ_EDITOR_EXTENSIONS = [
StarterKit.configure({
heading: { levels: [2, 3] },
link: false,
underline: false,
}),
UnderlineExtension,
LinkExtension.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }),
ImageExtension.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full' } }),
PlaceholderExtension.configure({ placeholder: '' }),
TextAlignExtension.configure({ types: ['heading', 'paragraph'] }),
HighlightExtension,
VideoExtension,
];
function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) {
const { t } = useTranslation();
const haptic = useHapticFeedback();
const mediaRef = useRef<HTMLInputElement>(null);
const [uploadCount, setUploadCount] = useState(0);
const isUploading = uploadCount > 0;
const [isDragging, setIsDragging] = useState(false);
const activeUploadsRef = useRef(new Set<AbortController>());
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const editor = useEditor({
extensions: FAQ_EDITOR_EXTENSIONS,
content: value,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none min-h-[120px] p-3 focus:outline-none',
},
},
onUpdate: ({ editor: e }) => {
const html = e.getHTML();
const isEmpty = html === '<p></p>' || html === '';
onChangeRef.current(isEmpty ? '' : html);
},
});
// Sync external value changes (e.g., reorder, locale switch)
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const currentHtml = editor.getHTML();
const normalizedCurrent = currentHtml === '<p></p>' ? '' : currentHtml;
if (normalizedCurrent !== value) {
editor.commands.setContent(value || '', { emitUpdate: false });
}
}, [value, editor]);
// Cleanup uploads on unmount
useEffect(() => {
const uploads = activeUploadsRef.current;
return () => {
for (const c of uploads) c.abort();
uploads.clear();
};
}, []);
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();
return;
}
const controller = new AbortController();
activeUploadsRef.current.add(controller);
setUploadCount((c) => c + 1);
try {
const result = await newsApi.uploadMedia(file, controller.signal);
if (controller.signal.aborted) return;
if (!isSafeUrl(result.url)) return;
if (result.media_type === 'image') {
editor.chain().focus().setImage({ src: result.url, alt: file.name }).run();
} else {
editor
.chain()
.focus()
.insertContent({
type: 'video',
attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' },
})
.run();
}
haptic.success();
} catch {
if (!controller.signal.aborted) haptic.error();
} finally {
activeUploadsRef.current.delete(controller);
setUploadCount((c) => c - 1);
}
},
[editor, haptic],
);
const handleMediaUploadRef = useRef(handleMediaUpload);
useEffect(() => {
handleMediaUploadRef.current = handleMediaUpload;
}, [handleMediaUpload]);
// Register paste/drop handlers after editor is created
useEffect(() => {
if (!editor) return;
const handlePaste = (_view: unknown, event: ClipboardEvent) => {
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;
};
const handleDrop = (_view: unknown, event: DragEvent) => {
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;
};
editor.setOptions({ editorProps: { ...editor.options.editorProps, handlePaste, handleDrop } });
}, [editor]);
const promptDialog = usePrompt();
const addLink = useCallback(async () => {
if (!editor) return;
const url = await promptDialog({
label: t('news.admin.toolbar.linkUrlPrompt'),
inputType: 'url',
});
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
}, [editor, t, promptDialog]);
if (!editor) return null;
return (
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50">
{/* Upload overlay */}
{isUploading && (
<div className="flex items-center justify-center gap-2 bg-dark-900/60 px-3 py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
<span className="text-xs text-dark-300">{t('news.admin.uploading')}</span>
</div>
)}
{isDragging && !isUploading && (
<div className="flex items-center justify-center border-b border-dashed border-accent-400 bg-accent-400/10 px-3 py-2">
<span className="text-xs font-medium text-accent-400">{t('news.admin.dropMedia')}</span>
</div>
)}
{/* Compact toolbar */}
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 px-1.5 py-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title={t('news.admin.toolbar.bold')}
>
<BoldIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title={t('news.admin.toolbar.italic')}
>
<ItalicIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title={t('news.admin.toolbar.underline')}
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title={t('news.admin.toolbar.strikethrough')}
>
<StrikeIcon className="h-4 w-4" />
</ToolbarButton>
<div className="mx-0.5 h-4 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title={t('news.admin.toolbar.heading2')}
>
<H2Icon />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title={t('news.admin.toolbar.bulletList')}
>
<ListBulletIcon />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title={t('news.admin.toolbar.orderedList')}
>
<ListOrderedIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title={t('news.admin.toolbar.blockquote')}
>
<QuoteIcon className="h-4 w-4" />
</ToolbarButton>
<div className="mx-0.5 h-4 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHighlight().run()}
isActive={editor.isActive('highlight')}
title={t('news.admin.toolbar.highlight')}
>
<HighlightIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => mediaRef.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 className="h-4 w-4" />
)}
</ToolbarButton>
</div>
{/* Editor */}
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
setIsDragging(false);
if (e.defaultPrevented) return;
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) {
e.preventDefault();
handleMediaUpload(file);
}
}}
>
<EditorContent editor={editor} />
</div>
{/* Hidden file input */}
<input
ref={mediaRef}
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleMediaUpload(file);
e.target.value = '';
}}
className="hidden"
aria-hidden="true"
/>
</div>
);
}
// --- FAQ Q&A Builder ---
interface FaqBuilderProps {
items: FaqItem[];
onChange: (items: FaqItem[]) => void;
locale: string;
localeLabel: string;
}
function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) {
const { t } = useTranslation();
// Stable keys for React — travel with items through reorder/delete
const keyCounter = useRef(0);
const [itemKeys, setItemKeys] = useState<number[]>(() => items.map(() => keyCounter.current++));
// Regenerate all keys on locale switch (items are semantically different)
useEffect(() => {
setItemKeys(items.map(() => keyCounter.current++));
}, [locale]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync key count when items added/removed within the same locale
useEffect(() => {
setItemKeys((prev) => {
if (prev.length === items.length) return prev;
if (items.length > prev.length) {
const newKeys = [...prev];
for (let i = prev.length; i < items.length; i++) {
newKeys.push(keyCounter.current++);
}
return newKeys;
}
return prev.slice(0, items.length);
});
}, [items.length]);
const handleQuestionChange = useCallback(
(index: number, value: string) => {
const updated = items.map((item, i) => (i === index ? { ...item, q: value } : item));
onChange(updated);
},
[items, onChange],
);
const handleAnswerChange = useCallback(
(index: number, value: string) => {
const updated = items.map((item, i) => (i === index ? { ...item, a: value } : item));
onChange(updated);
},
[items, onChange],
);
const handleRemove = useCallback(
(index: number) => {
setItemKeys((prev) => prev.filter((_, i) => i !== index));
onChange(items.filter((_, i) => i !== index));
},
[items, onChange],
);
const handleAdd = useCallback(() => {
const newKey = keyCounter.current++;
setItemKeys((prev) => [...prev, newKey]);
onChange([...items, { q: '', a: '' }]);
}, [items, onChange]);
const handleMoveUp = useCallback(
(index: number) => {
if (index === 0) return;
const updated = [...items];
[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]];
setItemKeys((prev) => {
const k = [...prev];
[k[index - 1], k[index]] = [k[index], k[index - 1]];
return k;
});
onChange(updated);
},
[items, onChange],
);
const handleMoveDown = useCallback(
(index: number) => {
if (index >= items.length - 1) return;
const updated = [...items];
[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]];
setItemKeys((prev) => {
const k = [...prev];
[k[index], k[index + 1]] = [k[index + 1], k[index]];
return k;
});
onChange(updated);
},
[items, onChange],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="label">
{t('admin.infoPages.faq.questions')} ({localeLabel})
</label>
<span className="text-xs text-dark-500">
{items.length} {t('admin.infoPages.faq.questionsCount')}
</span>
</div>
{items.length === 0 && (
<div className="rounded-xl border border-dashed border-dark-700 bg-dark-800/30 p-6 text-center text-sm text-dark-500">
{t('admin.infoPages.faq.noQuestions')}
</div>
)}
{items.map((item, index) => (
<div
key={itemKeys[index] ?? index}
className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-dark-400">
{t('admin.infoPages.faq.questionNumber', { n: index + 1 })}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
title={t('admin.infoPages.faq.moveUp')}
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleMoveDown(index)}
disabled={index >= items.length - 1}
className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
title={t('admin.infoPages.faq.moveDown')}
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleRemove(index)}
className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
title={t('admin.infoPages.faq.removeQuestion')}
>
<TrashSmallIcon />
</button>
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-dark-400">
{t('admin.infoPages.faq.question')}
</label>
<input
type="text"
value={item.q}
onChange={(e) => handleQuestionChange(index, e.target.value)}
className="input text-sm"
placeholder={t('admin.infoPages.faq.questionPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-dark-400">
{t('admin.infoPages.faq.answer')}
</label>
<FaqAnswerEditor
value={item.a}
onChange={(html) => handleAnswerChange(index, html)}
/>
</div>
</div>
</div>
))}
<button
type="button"
onClick={handleAdd}
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/30 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:bg-dark-800/50 hover:text-dark-100"
>
<PlusSmallIcon className="h-5 w-5" />
{t('admin.infoPages.faq.addQuestion')}
</button>
</div>
);
}
export default function AdminInfoPageEditor() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id: rawId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const pageId = rawId != null ? Number(rawId) : undefined;
const isEdit = pageId != null && !Number.isNaN(pageId);
const initialPageType = (searchParams.get('type') === 'faq' ? 'faq' : 'page') as InfoPageType;
// Form state
const [slug, setSlug] = useState('');
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [icon, setIcon] = useState('');
const [isActive, setIsActive] = useState(true);
const [sortOrder, setSortOrder] = useState(0);
const [pageType, setPageType] = useState<InfoPageType>(initialPageType);
const [replacesTab, setReplacesTab] = useState<ReplacesTab | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// FAQ Q&A state per locale
const [faqItems, setFaqItems] = useState<Record<string, FaqItem[]>>({});
// Multi-locale state
const [activeLocale, setActiveLocale] = useState<LocaleCode>('ru');
const [titles, setTitles] = useState<Record<string, string>>({});
const [contents, setContents] = useState<Record<string, string>>({});
// Media upload state
const mediaInputRef = useRef<HTMLInputElement>(null);
const [uploadCount, setUploadCount] = useState(0);
const isUploading = uploadCount > 0;
const [isDragging, setIsDragging] = useState(false);
const activeUploadsRef = useRef(new Set<AbortController>());
const handleMediaUploadRef = useRef<(file: File) => void>(() => {});
// TipTap editor
const extensions = useMemo(
() => [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
underline: false,
}),
UnderlineExtension,
LinkExtension.configure({
openOnClick: false,
HTMLAttributes: { class: 'link' },
}),
ImageExtension.configure({
HTMLAttributes: { class: 'rounded-xl max-w-full' },
}),
PlaceholderExtension.configure({
placeholder: t('admin.infoPages.fields.content'),
}),
TextAlignExtension.configure({
types: ['heading', 'paragraph'],
}),
HighlightExtension,
VideoExtension,
],
// 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',
},
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;
}
const controller = new AbortController();
activeUploadsRef.current.add(controller);
setUploadCount((c) => c + 1);
try {
const result = await newsApi.uploadMedia(file, controller.signal);
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 {
editor
.chain()
.focus()
.insertContent({
type: 'video',
attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' },
})
.run();
}
haptic.success();
} catch {
if (controller.signal.aborted) return;
haptic.error();
setSaveError(t('news.admin.uploadError'));
} finally {
activeUploadsRef.current.delete(controller);
setUploadCount((c) => c - 1);
}
},
[editor, haptic, t],
);
useEffect(() => {
handleMediaUploadRef.current = handleMediaUpload;
}, [handleMediaUpload]);
useEffect(() => {
const uploads = activeUploadsRef.current;
return () => {
for (const controller of uploads) {
controller.abort();
}
uploads.clear();
};
}, []);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleMediaUpload(file);
e.target.value = '';
},
[handleMediaUpload],
);
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) => {
setIsDragging(false);
if (e.defaultPrevented) return;
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleMediaUpload(file);
},
[handleMediaUpload],
);
// Fetch all admin pages to detect tab conflicts
const { data: allAdminPages } = useQuery({
queryKey: ['admin', 'info-pages', 'list', 'all'],
queryFn: () => infoPagesApi.getAdminPages(),
staleTime: 30_000,
});
// Fetch page for editing
const { data: pageData, isLoading: isLoadingPage } = useQuery({
queryKey: ['admin', 'info-pages', 'page', pageId],
queryFn: () => {
if (pageId == null) throw new Error('Missing page id parameter');
return infoPagesApi.getAdminPage(pageId);
},
enabled: isEdit,
staleTime: 0,
gcTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: false,
});
// Populate form when page data loads (once only -- not on locale switch)
const editorPopulated = useRef(false);
const formPopulated = useRef(false);
useEffect(() => {
if (!pageData || formPopulated.current) return;
setSlug(pageData.slug);
setSlugManuallyEdited(true);
setIcon(pageData.icon ?? '');
setIsActive(pageData.is_active);
setSortOrder(pageData.sort_order);
setPageType(pageData.page_type ?? 'page');
setReplacesTab(pageData.replaces_tab ?? null);
setTitles(pageData.title);
if (pageData.page_type === 'faq') {
// For FAQ pages, content stores Q&A arrays per locale
const parsed: Record<string, FaqItem[]> = {};
for (const [loc, val] of Object.entries(pageData.content)) {
try {
const arr = typeof val === 'string' ? JSON.parse(val) : val;
parsed[loc] = Array.isArray(arr) ? arr : [];
} catch {
parsed[loc] = [];
}
}
setFaqItems(parsed);
setContents({});
} else {
setContents(pageData.content);
}
formPopulated.current = true;
}, [pageData]);
// Set editor content once when editor is ready
useEffect(() => {
if (!pageData || !editor || editorPopulated.current) return;
const initialContent = pageData.content[activeLocale] ?? pageData.content['ru'] ?? '';
editor.commands.setContent(initialContent);
editorPopulated.current = true;
// activeLocale intentionally omitted — initial content is locked at editor
// mount; subsequent locale changes are routed through switchLocale().
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageData, editor]);
// Auto-generate slug from Russian title
useEffect(() => {
if (!slugManuallyEdited && titles['ru']) {
setSlug(generateSlug(titles['ru']));
}
}, [titles, slugManuallyEdited]);
// --- Locale switching ---
const switchLocale = useCallback(
(newLocale: LocaleCode) => {
if (!editor) return;
// Save current editor content
const currentHtml = editor.getHTML();
const isEmpty = currentHtml === '<p></p>' || currentHtml === '';
setContents((prev) => ({
...prev,
[activeLocale]: isEmpty ? '' : currentHtml,
}));
// Load new locale content
const newContent = contents[newLocale] ?? '';
editor.commands.setContent(newContent);
setActiveLocale(newLocale);
},
[editor, activeLocale, contents],
);
// Save mutation
const saveMutation = useMutation({
mutationFn: (data: {
slug: string;
title: Record<string, string>;
content: Record<string, string>;
page_type: InfoPageType;
is_active: boolean;
sort_order: number;
icon: string | null;
replaces_tab: ReplacesTab | null;
}) => {
if (isEdit && pageId != null) {
return infoPagesApi.updatePage(pageId, data);
}
return infoPagesApi.createPage(data);
},
onSuccess: () => {
haptic.success();
queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] });
queryClient.invalidateQueries({ queryKey: ['info-pages'] });
navigate('/admin/info-pages');
},
onError: (error: Error) => {
haptic.error();
setSaveError(error.message || t('admin.infoPages.saveError'));
},
});
const handleSave = () => {
setSaveError(null);
if (!slug.trim()) return;
let finalContents: Record<string, string>;
if (pageType === 'faq') {
// Serialize FAQ items as JSON strings per locale
finalContents = {};
for (const [loc, items] of Object.entries(faqItems)) {
finalContents[loc] = JSON.stringify(items);
}
} else {
// Capture current editor content for the active locale
const currentHtml = editor?.getHTML() ?? '';
const isEmpty = currentHtml === '<p></p>' || currentHtml === '';
finalContents = {
...contents,
[activeLocale]: isEmpty ? '' : currentHtml,
};
}
const data = {
slug: slug.trim(),
title: titles,
content: finalContents,
page_type: pageType,
is_active: isActive,
sort_order: sortOrder,
icon: icon.trim() || null,
replaces_tab: replacesTab,
};
haptic.buttonPress();
saveMutation.mutate(data);
};
// Toolbar actions
const promptDialog = usePrompt();
const addLink = async () => {
if (!editor) return;
const url = await promptDialog({
label: t('news.admin.toolbar.linkUrlPrompt'),
inputType: 'url',
});
if (!url) return;
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 && isLoadingPage) {
return (
<div className="space-y-6">
<div className="skeleton h-8 w-48 rounded-lg" />
<div className="skeleton h-12 w-full rounded-xl" />
<div className="skeleton h-64 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/info-pages" />
<h1 className="text-xl font-bold text-dark-100">
{isEdit ? t('admin.infoPages.edit') : t('admin.infoPages.create')}
</h1>
</div>
<button
onClick={handleSave}
disabled={saveMutation.isPending || !slug.trim()}
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.infoPages.saving') : t('admin.infoPages.save')}
</button>
</div>
{/* Form */}
<div className="space-y-5">
{/* Slug */}
<div>
<label htmlFor="ip-slug" className="label">
{t('admin.infoPages.fields.slug')}
</label>
<input
id="ip-slug"
type="text"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugManuallyEdited(true);
}}
className="input font-mono text-sm"
required
/>
</div>
{/* Icon + Sort Order row */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="ip-icon" className="label">
{t('admin.infoPages.fields.icon')}
</label>
<input
id="ip-icon"
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="input"
placeholder="📄"
/>
</div>
<div>
<label htmlFor="ip-sortorder" className="label">
{t('admin.infoPages.fields.sortOrder')}
</label>
<input
id="ip-sortorder"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value) || 0)}
min={0}
className="input max-w-xs"
/>
</div>
</div>
{/* Active toggle */}
<div className="flex items-center gap-3">
<Toggle
checked={isActive}
onChange={() => setIsActive((v) => !v)}
aria-label={t('admin.infoPages.fields.isActive')}
/>
<span className="text-sm text-dark-300">{t('admin.infoPages.fields.isActive')}</span>
</div>
{/* Page type selector */}
<div>
<label id="ip-pagetype-label" className="label">
{t('admin.infoPages.fields.pageType')}
</label>
<div className="flex gap-1" role="radiogroup" aria-labelledby="ip-pagetype-label">
{(['page', 'faq'] as const).map((pt) => (
<button
key={pt}
type="button"
role="radio"
aria-checked={pageType === pt}
onClick={() => setPageType(pt)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
pageType === pt
? pt === 'faq'
? 'bg-warning-500 text-white'
: 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{t(`admin.infoPages.pageTypes.${pt}`)}
</button>
))}
</div>
</div>
{/* Replaces tab selector */}
<div>
<label htmlFor="ip-replaces-tab" className="label">
{t('admin.infoPages.fields.replacesTab')}
</label>
<select
id="ip-replaces-tab"
value={replacesTab ?? ''}
onChange={(e) => setReplacesTab((e.target.value || null) as ReplacesTab | null)}
className="input max-w-xs"
>
<option value="">{t('admin.infoPages.replacesTabNone')}</option>
{(['faq', 'rules', 'privacy', 'offer'] as const).map((tab) => {
const conflict = allAdminPages?.find(
(p) => p.replaces_tab === tab && p.id !== pageId,
);
return (
<option key={tab} value={tab}>
{t(`admin.infoPages.replacesTabOptions.${tab}`)}
{conflict ? ` (${t('admin.infoPages.replacesTabConflict')})` : ''}
</option>
);
})}
</select>
{replacesTab &&
allAdminPages?.some((p) => p.replaces_tab === replacesTab && p.id !== pageId) && (
<p className="mt-1 text-xs text-warning-400">
{t('admin.infoPages.replacesTabWarning')}
</p>
)}
</div>
{/* Locale tabs */}
<div>
<label id="ip-locale-label" className="label">
{t('admin.infoPages.localeLabel')}
</label>
<div className="flex flex-wrap gap-1" role="radiogroup" aria-labelledby="ip-locale-label">
{AVAILABLE_LOCALES.map((loc) => (
<button
key={loc}
type="button"
role="radio"
aria-checked={activeLocale === loc}
onClick={() => switchLocale(loc)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
activeLocale === loc
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{t(`admin.infoPages.locales.${loc}`)}
</button>
))}
</div>
</div>
{/* Title for current locale */}
<div>
<label htmlFor="ip-title" className="label">
{t('admin.infoPages.fields.title')} ({t(`admin.infoPages.locales.${activeLocale}`)})
</label>
<input
id="ip-title"
type="text"
value={titles[activeLocale] ?? ''}
onChange={(e) => setTitles((prev) => ({ ...prev, [activeLocale]: e.target.value }))}
className="input"
/>
</div>
{/* Content: TipTap editor for pages, FAQ builder for FAQ */}
{pageType === 'faq' ? (
<FaqBuilder
items={faqItems[activeLocale] ?? []}
onChange={(items) => setFaqItems((prev) => ({ ...prev, [activeLocale]: items }))}
locale={activeLocale}
localeLabel={t(`admin.infoPages.locales.${activeLocale}`)}
/>
) : (
<div>
<label className="label">
{t('admin.infoPages.fields.content')} ({t(`admin.infoPages.locales.${activeLocale}`)})
</label>
<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">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title={t('news.admin.toolbar.bold')}
>
<BoldIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title={t('news.admin.toolbar.italic')}
>
<ItalicIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title={t('news.admin.toolbar.underline')}
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title={t('news.admin.toolbar.strikethrough')}
>
<StrikeIcon className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title={t('news.admin.toolbar.heading1')}
>
<H1Icon />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title={t('news.admin.toolbar.heading2')}
>
<H2Icon />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title={t('news.admin.toolbar.heading3')}
>
<H3Icon />
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title={t('news.admin.toolbar.bulletList')}
>
<ListBulletIcon />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title={t('news.admin.toolbar.orderedList')}
>
<ListOrderedIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title={t('news.admin.toolbar.blockquote')}
>
<QuoteIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title={t('news.admin.toolbar.codeBlock')}
>
<CodeBlockIcon className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title={t('news.admin.toolbar.alignLeft')}
>
<AlignLeftIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title={t('news.admin.toolbar.alignCenter')}
>
<AlignCenterIcon className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHighlight().run()}
isActive={editor.isActive('highlight')}
title={t('news.admin.toolbar.highlight')}
>
<HighlightIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
<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 className="h-4 w-4" />
)}
</ToolbarButton>
</div>
)}
{/* Editor content */}
<EditorContent editor={editor} />
</div>
{/* Hidden file inputs */}
<input
ref={mediaInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm"
onChange={handleFileInputChange}
className="hidden"
aria-hidden="true"
/>
</div>
)}
{/* Error feedback */}
{saveError && (
<div className="rounded-lg border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
{saveError}
</div>
)}
{/* Bottom save button */}
<button
onClick={handleSave}
disabled={saveMutation.isPending || !slug.trim()}
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.infoPages.saving') : t('admin.infoPages.save')}
</button>
</div>
</div>
);
}