From 7d6d0ba3442bf683b6c99d2af2b4b3fb287beb11 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 08:11:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20information=20pages=20=E2=80=94=20admin?= =?UTF-8?q?=20editor=20with=20TipTap,=20public=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin panel: - New section "Информационные страницы" in admin nav - Admin list: slug, title, active toggle, edit/delete, sort order - Admin editor: full TipTap (bold, italic, headings, lists, links, image/video upload, alignment, highlight, code blocks) with locale tabs (RU/EN/ZH/FA) for title and content - Create/edit via URL params, slug auto-generation Public: - /info/:slug route with DOMPurify sanitized content - Locale fallback, Telegram safe area, loading skeleton, 404 i18n: all 4 locales --- src/App.tsx | 47 ++ src/api/infoPages.ts | 96 ++++ src/locales/en.json | 34 +- src/locales/fa.json | 34 +- src/locales/ru.json | 34 +- src/locales/zh.json | 34 +- src/pages/AdminInfoPageEditor.tsx | 833 ++++++++++++++++++++++++++++++ src/pages/AdminInfoPages.tsx | 346 +++++++++++++ src/pages/AdminPanel.tsx | 15 + src/pages/InfoPageView.tsx | 279 ++++++++++ 10 files changed, 1748 insertions(+), 4 deletions(-) create mode 100644 src/api/infoPages.ts create mode 100644 src/pages/AdminInfoPageEditor.tsx create mode 100644 src/pages/AdminInfoPages.tsx create mode 100644 src/pages/InfoPageView.tsx diff --git a/src/App.tsx b/src/App.tsx index 0c8423c..096c55c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -148,6 +148,11 @@ const NewsArticlePage = lazyWithRetry(() => import('./pages/NewsArticle')); const AdminNews = lazyWithRetry(() => import('./pages/AdminNews')); const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate')); +// Info pages +const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView')); +const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages')); +const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor')); + function ProtectedRoute({ children, withLayout = true, @@ -555,6 +560,16 @@ function App() { } /> + + + + + + } + /> {/* Admin routes */} + {/* Info pages admin routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + ; + content: Record; + is_active: boolean; + sort_order: number; + icon: string | null; + created_at: string; + updated_at: string | null; +} + +export interface InfoPageListItem { + id: number; + slug: string; + title: Record; + is_active: boolean; + sort_order: number; + icon: string | null; + updated_at: string | null; +} + +export interface InfoPageCreateRequest { + slug: string; + title: Record; + content: Record; + is_active: boolean; + sort_order: number; + icon: string | null; +} + +export interface InfoPageUpdateRequest { + slug?: string; + title?: Record; + content?: Record; + is_active?: boolean; + sort_order?: number; + icon?: string | null; +} + +export interface InfoPageReorderRequest { + items: Array<{ id: number; sort_order: number }>; +} + +export const infoPagesApi = { + // Public endpoints + getPages: async (): Promise => { + const response = await apiClient.get('/cabinet/info-pages'); + return response.data; + }, + + getPageBySlug: async (slug: string): Promise => { + const response = await apiClient.get( + `/cabinet/info-pages/${encodeURIComponent(slug)}`, + ); + return response.data; + }, + + // Admin endpoints + getAdminPages: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/info-pages'); + return response.data; + }, + + getAdminPage: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/info-pages/${id}`); + return response.data; + }, + + createPage: async (data: InfoPageCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/info-pages', data); + return response.data; + }, + + updatePage: async (id: number, data: InfoPageUpdateRequest): Promise => { + const response = await apiClient.put(`/cabinet/admin/info-pages/${id}`, data); + return response.data; + }, + + deletePage: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/info-pages/${id}`); + }, + + toggleActive: async (id: number): Promise => { + const response = await apiClient.post( + `/cabinet/admin/info-pages/${id}/toggle-active`, + ); + return response.data; + }, + + reorder: async (data: InfoPageReorderRequest): Promise => { + await apiClient.post('/cabinet/admin/info-pages/reorder', data); + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index cd5f6de..a5eda77 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1126,7 +1126,8 @@ "landings": "Landings", "referralNetwork": "Referral Network", "news": "News", - "bulkActions": "Bulk Actions" + "bulkActions": "Bulk Actions", + "infoPages": "Info Pages" }, "panel": { "title": "Admin Panel", @@ -3798,6 +3799,37 @@ "prev": "Previous", "next": "Next" } + }, + "infoPages": { + "title": "Information Pages", + "subtitle": "Manage static information pages", + "create": "Create Page", + "edit": "Edit", + "save": "Save", + "saving": "Saving...", + "saved": "Page saved", + "delete": "Delete", + "confirmDelete": "Delete this page?", + "saveError": "Failed to save page. Please try again.", + "noPages": "No information pages yet", + "notFound": "Page not found", + "active": "Active", + "inactive": "Inactive", + "localeLabel": "Content Language", + "fields": { + "slug": "URL Slug", + "title": "Title", + "content": "Content", + "isActive": "Active", + "sortOrder": "Sort Order", + "icon": "Icon (emoji)" + }, + "locales": { + "ru": "Russian", + "en": "English", + "zh": "Chinese", + "fa": "Persian" + } } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index fbe1cde..a570208 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -949,7 +949,8 @@ "salesStats": "آمار فروش", "landings": "صفحات فرود", "referralNetwork": "شبکه ارجاع", - "news": "اخبار" + "news": "اخبار", + "infoPages": "صفحات اطلاعات" }, "panel": { "title": "پنل مدیریت", @@ -3366,6 +3367,37 @@ "periodDaySuffix": "روز", "localeTab": "زبان", "localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید" + }, + "infoPages": { + "title": "صفحات اطلاعات", + "subtitle": "مدیریت صفحات اطلاعات ثابت", + "create": "ایجاد صفحه", + "edit": "ویرایش", + "save": "ذخیره", + "saving": "در حال ذخیره...", + "saved": "صفحه ذخیره شد", + "delete": "حذف", + "confirmDelete": "این صفحه حذف شود؟", + "saveError": "ذخیره صفحه ناموفق بود. لطفاً دوباره تلاش کنید.", + "noPages": "هنوز صفحه اطلاعاتی وجود ندارد", + "notFound": "صفحه پیدا نشد", + "active": "فعال", + "inactive": "غیرفعال", + "localeLabel": "زبان محتوا", + "fields": { + "slug": "شناسه URL", + "title": "عنوان", + "content": "محتوا", + "isActive": "فعال", + "sortOrder": "ترتیب", + "icon": "آیکون (ایموجی)" + }, + "locales": { + "ru": "روسی", + "en": "انگلیسی", + "zh": "چینی", + "fa": "فارسی" + } } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 5c761d8..9f4def2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1147,7 +1147,8 @@ "landings": "Лендинги", "referralNetwork": "Реферальная сеть", "news": "Новости", - "bulkActions": "Массовые действия" + "bulkActions": "Массовые действия", + "infoPages": "Инфо-страницы" }, "panel": { "title": "Панель администратора", @@ -4342,6 +4343,37 @@ "prev": "Назад", "next": "Далее" } + }, + "infoPages": { + "title": "Информационные страницы", + "subtitle": "Управление статическими информационными страницами", + "create": "Создать страницу", + "edit": "Редактировать", + "save": "Сохранить", + "saving": "Сохранение...", + "saved": "Страница сохранена", + "delete": "Удалить", + "confirmDelete": "Удалить эту страницу?", + "saveError": "Не удалось сохранить страницу. Попробуйте ещё раз.", + "noPages": "Информационных страниц пока нет", + "notFound": "Страница не найдена", + "active": "Активна", + "inactive": "Неактивна", + "localeLabel": "Язык контента", + "fields": { + "slug": "URL-адрес", + "title": "Заголовок", + "content": "Содержание", + "isActive": "Активна", + "sortOrder": "Порядок сортировки", + "icon": "Иконка (эмодзи)" + }, + "locales": { + "ru": "Русский", + "en": "Английский", + "zh": "Китайский", + "fa": "Персидский" + } } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 5d775b2..0eb5726 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -949,7 +949,8 @@ "salesStats": "销售统计", "landings": "落地页", "referralNetwork": "推荐网络", - "news": "新闻" + "news": "新闻", + "infoPages": "信息页面" }, "panel": { "title": "管理面板", @@ -3365,6 +3366,37 @@ "periodDaySuffix": "天", "localeTab": "语言", "localeHint": "切换语言以编辑该语言的文本" + }, + "infoPages": { + "title": "信息页面", + "subtitle": "管理静态信息页面", + "create": "创建页面", + "edit": "编辑", + "save": "保存", + "saving": "保存中...", + "saved": "页面已保存", + "delete": "删除", + "confirmDelete": "确定删除此页面?", + "saveError": "保存页面失败,请重试。", + "noPages": "暂无信息页面", + "notFound": "页面未找到", + "active": "已激活", + "inactive": "未激活", + "localeLabel": "内容语言", + "fields": { + "slug": "URL标识", + "title": "标题", + "content": "内容", + "isActive": "已激活", + "sortOrder": "排序", + "icon": "图标(表情)" + }, + "locales": { + "ru": "俄语", + "en": "英语", + "zh": "中文", + "fa": "波斯语" + } } }, "adminUpdates": { diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx new file mode 100644 index 0000000..c4e7281 --- /dev/null +++ b/src/pages/AdminInfoPageEditor.tsx @@ -0,0 +1,833 @@ +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'; +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 { AdminBackButton } from '../components/admin'; +import { Toggle } from '../components/admin/Toggle'; +import { useHapticFeedback } from '../platform/hooks/useHaptic'; +import { cn } from '../lib/utils'; + +const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; +type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; + +// --- 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; + disabled?: boolean; + title: string; + children: React.ReactNode; +} + +function ToolbarButton({ onClick, isActive, disabled, 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 --- +const TRANSLIT_MAP: Record = { + а: '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); +} + +export default function AdminInfoPageEditor() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id: rawId } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const pageId = rawId != null ? Number(rawId) : undefined; + const isEdit = pageId != null && !Number.isNaN(pageId); + + // 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 [saveError, setSaveError] = useState(null); + + // Multi-locale state + const [activeLocale, setActiveLocale] = useState('ru'); + const [titles, setTitles] = useState>({}); + const [contents, setContents] = useState>({}); + + // Media upload state + const mediaInputRef = useRef(null); + const [uploadCount, setUploadCount] = useState(0); + const isUploading = uploadCount > 0; + const [isDragging, setIsDragging] = useState(false); + const activeUploadsRef = useRef(new Set()); + + 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) => { + 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 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 + const editorPopulated = useRef(false); + useEffect(() => { + if (!pageData) return; + setSlug(pageData.slug); + setSlugManuallyEdited(true); + setIcon(pageData.icon ?? ''); + setIsActive(pageData.is_active); + setSortOrder(pageData.sort_order); + setTitles(pageData.title); + setContents(pageData.content); + if (editor && pageData.content[activeLocale] && !editorPopulated.current) { + editor.commands.setContent(pageData.content[activeLocale] ?? ''); + editorPopulated.current = true; + } + }, [pageData, editor, activeLocale]); + + // 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 === '

' || 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; + content: Record; + is_active: boolean; + sort_order: number; + icon: string | 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; + + // Capture current editor content for the active locale + const currentHtml = editor?.getHTML() ?? ''; + const isEmpty = currentHtml === '

' || currentHtml === ''; + const finalContents = { + ...contents, + [activeLocale]: isEmpty ? '' : currentHtml, + }; + + const data = { + slug: slug.trim(), + title: titles, + content: finalContents, + is_active: isActive, + sort_order: sortOrder, + icon: icon.trim() || null, + }; + + haptic.buttonPress(); + saveMutation.mutate(data); + }; + + // Toolbar actions + 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 && isLoadingPage) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

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

+
+ +
+ + {/* Form */} +
+ {/* Slug */} +
+ + { + setSlug(e.target.value); + setSlugManuallyEdited(true); + }} + className="input font-mono text-sm" + required + /> +
+ + {/* Icon + Sort Order row */} +
+
+ + setIcon(e.target.value)} + className="input" + placeholder="📄" + /> +
+
+ + setSortOrder(Number(e.target.value) || 0)} + min={0} + className="input max-w-xs" + /> +
+
+ + {/* Active toggle */} +
+ setIsActive((v) => !v)} + aria-label={t('admin.infoPages.fields.isActive')} + /> + {t('admin.infoPages.fields.isActive')} +
+ + {/* Locale tabs */} +
+ +
+ {AVAILABLE_LOCALES.map((loc) => ( + + ))} +
+
+ + {/* Title for current locale */} +
+ + setTitles((prev) => ({ ...prev, [activeLocale]: e.target.value }))} + className="input" + /> +
+ + {/* Content editor */} +
+ +
+ {/* Upload progress overlay */} + {isUploading && ( +
+
+
+ + {t('news.admin.uploading')} + +
+
+ )} + + {/* Drag overlay */} + {isDragging && !isUploading && ( +
+ + {t('news.admin.dropMedia')} + +
+ )} + + {/* Toolbar */} + {editor && ( +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title={t('news.admin.toolbar.bold')} + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title={t('news.admin.toolbar.italic')} + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title={t('news.admin.toolbar.underline')} + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + title={t('news.admin.toolbar.strikethrough')} + > + + + +
+ + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive('heading', { level: 1 })} + title={t('news.admin.toolbar.heading1')} + > + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title={t('news.admin.toolbar.heading2')} + > + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive('heading', { level: 3 })} + title={t('news.admin.toolbar.heading3')} + > + + + +
+ + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title={t('news.admin.toolbar.bulletList')} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title={t('news.admin.toolbar.orderedList')} + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title={t('news.admin.toolbar.blockquote')} + > + + + editor.chain().focus().toggleCodeBlock().run()} + isActive={editor.isActive('codeBlock')} + title={t('news.admin.toolbar.codeBlock')} + > + + + +
+ + editor.chain().focus().setTextAlign('left').run()} + isActive={editor.isActive({ textAlign: 'left' })} + title={t('news.admin.toolbar.alignLeft')} + > + + + editor.chain().focus().setTextAlign('center').run()} + isActive={editor.isActive({ textAlign: 'center' })} + title={t('news.admin.toolbar.alignCenter')} + > + + + +
+ + editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive('highlight')} + title={t('news.admin.toolbar.highlight')} + > + + + + + + mediaInputRef.current?.click()} + disabled={isUploading} + title={t('news.admin.toolbar.image')} + > + {isUploading ? ( +
+ ) : ( + + )} + +
+ )} + + {/* Editor content */} + +
+ + {/* Hidden file inputs */} + +
+ + {/* Error feedback */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Bottom save button */} + +
+
+ ); +} diff --git a/src/pages/AdminInfoPages.tsx b/src/pages/AdminInfoPages.tsx new file mode 100644 index 0000000..bc8b1d9 --- /dev/null +++ b/src/pages/AdminInfoPages.tsx @@ -0,0 +1,346 @@ +import { useCallback, memo } from 'react'; +import { useNavigate } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { infoPagesApi } from '../api/infoPages'; +import { AdminBackButton } from '../components/admin'; +import { Toggle } from '../components/admin/Toggle'; +import { useHapticFeedback } from '../platform/hooks/useHaptic'; +import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; +import type { InfoPageListItem } from '../api/infoPages'; + +// Icons +const PlusIcon = () => ( + +); + +const RefreshIcon = () => ( + +); + +const PencilIcon = () => ( + +); + +const TrashIcon = () => ( + +); + +const FileTextIcon = () => ( + +); + +// --- Page Row --- + +const PageRow = memo(function PageRow({ + page, + locale, + onEdit, + onDelete, + onToggleActive, +}: { + page: InfoPageListItem; + locale: string; + onEdit: () => void; + onDelete: () => void; + onToggleActive: () => void; +}) { + const { t } = useTranslation(); + const resolvedTitle = page.title[locale] || page.title['ru'] || page.title['en'] || ''; + + return ( +
+
+
+
+ {page.icon && {page.icon}} + + /{page.slug} + + + {page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')} + + #{page.id} +
+ +

{resolvedTitle}

+ +
+ + {t('admin.infoPages.fields.sortOrder')}: {page.sort_order} + + {page.updated_at && {new Date(page.updated_at).toLocaleDateString()}} +
+
+ +
+ + + +
+
+
+ ); +}); + +// --- Row Wrapper (stable callbacks for memo) --- + +interface PageRowWrapperProps { + page: InfoPageListItem; + locale: string; + onNavigate: (path: string) => void; + onDelete: (id: number) => void; + onToggleActive: (id: number) => void; +} + +const PageRowWrapper = memo(function PageRowWrapper({ + page, + locale, + onNavigate, + onDelete, + onToggleActive, +}: PageRowWrapperProps) { + const handleEdit = useCallback( + () => onNavigate(`/admin/info-pages/${page.id}/edit`), + [page.id, onNavigate], + ); + const handleDelete = useCallback(() => onDelete(page.id), [page.id, onDelete]); + const handleToggleActive = useCallback(() => onToggleActive(page.id), [page.id, onToggleActive]); + + return ( + + ); +}); + +export default function AdminInfoPages() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const confirm = useDestructiveConfirm(); + const currentLocale = i18n.language.split('-')[0]; + + const { + data: pages, + isLoading, + refetch, + } = useQuery({ + queryKey: ['admin', 'info-pages', 'list'], + queryFn: () => infoPagesApi.getAdminPages(), + staleTime: 30_000, + }); + + const items = pages ?? []; + + const deleteMutation = useMutation({ + mutationFn: infoPagesApi.deletePage, + onSuccess: () => { + haptic.success(); + queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] }); + queryClient.invalidateQueries({ queryKey: ['info-pages'] }); + }, + }); + + const toggleActiveMutation = useMutation({ + mutationFn: infoPagesApi.toggleActive, + onSuccess: () => { + haptic.success(); + queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] }); + queryClient.invalidateQueries({ queryKey: ['info-pages'] }); + }, + }); + + const handleDelete = useCallback( + async (id: number) => { + const confirmed = await confirm(t('admin.infoPages.confirmDelete')); + if (confirmed) { + deleteMutation.mutate(id); + } + }, + [confirm, deleteMutation, t], + ); + + const handleToggleActive = useCallback( + (id: number) => { + toggleActiveMutation.mutate(id); + }, + [toggleActiveMutation], + ); + + return ( +
+ {/* Header */} +
+
+ +
+

{t('admin.infoPages.title')}

+ {items.length > 0 && ( + + {items.length} + + )} +
+
+
+ + +
+
+ + {/* Pages list */} + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+ +

{t('admin.infoPages.noPages')}

+
+ ) : ( +
+ {items.map((page) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 94470e4..24db4c2 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -345,6 +345,15 @@ const icons = { ), + 'file-text': ( + + + + + + + + ), chevron: ( @@ -561,6 +570,12 @@ const sections: AdminSection[] = [ to: '/admin/email-templates', permission: 'email_templates:read', }, + { + name: 'admin.nav.infoPages', + icon: 'file-text', + to: '/admin/info-pages', + permission: 'settings:read', + }, { name: 'admin.nav.updates', icon: 'refresh', diff --git a/src/pages/InfoPageView.tsx b/src/pages/InfoPageView.tsx new file mode 100644 index 0000000..a0bc401 --- /dev/null +++ b/src/pages/InfoPageView.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import DOMPurify from 'dompurify'; +import { infoPagesApi } from '../api/infoPages'; +import { usePlatform } from '../platform/hooks/usePlatform'; + +// Icons +const BackIcon = () => ( + + + +); + +/** + * Sanitization config — same strict allowlist as NewsArticlePage. + * All HTML content is sanitized with DOMPurify before rendering. + */ +const ALLOWED_IFRAME_HOSTS = new Set([ + 'www.youtube.com', + 'youtube.com', + 'player.vimeo.com', + 'www.youtube-nocookie.com', +]); + +function isAllowedIframeSrc(src: string): boolean { + try { + const url = new URL(src); + return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname); + } catch { + return false; + } +} + +const SANITIZE_CONFIG = { + ALLOWED_TAGS: [ + 'p', + 'div', + 'br', + 'hr', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'a', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'del', + 'ins', + 'span', + 'mark', + 'sub', + 'sup', + 'small', + 'img', + 'video', + 'iframe', + 'figure', + 'figcaption', + ], + ALLOWED_ATTR: [ + 'href', + 'target', + 'rel', + 'src', + 'alt', + 'title', + 'width', + 'height', + 'loading', + 'class', + 'start', + 'reversed', + 'type', + 'controls', + 'preload', + 'frameborder', + 'allowfullscreen', + 'allow', + 'sandbox', + 'style', + ], + ALLOW_DATA_ATTR: false, + ADD_ATTR: ['target'], +}; + +/** + * Isolated DOMPurify instance for info page content sanitization. + * All user-generated HTML is sanitized before being rendered. + */ +const infoPagePurify = DOMPurify(window); + +infoPagePurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + const src = node.getAttribute('src') ?? ''; + if (!isAllowedIframeSrc(src)) { + node.remove(); + return; + } + node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + } +}); + +infoPagePurify.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; + } + node.setAttribute('controls', ''); + node.setAttribute('preload', 'metadata'); + } +}); + +infoPagePurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } +}); + +infoPagePurify.addHook('afterSanitizeAttributes', (node) => { + if (node.hasAttribute('style')) { + const style = node.getAttribute('style') ?? ''; + const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i); + if (match) { + node.setAttribute('style', `text-align: ${match[1]}`); + } else { + node.removeAttribute('style'); + } + } +}); + +function sanitizeHtml(html: string): string { + return infoPagePurify.sanitize(html, SANITIZE_CONFIG); +} + +export default function InfoPageView() { + const { slug } = useParams<{ slug: string }>(); + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const { capabilities, backButton } = usePlatform(); + + const navigateRef = useRef(navigate); + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + + useEffect(() => { + if (!capabilities.hasBackButton) return; + backButton.show(() => navigateRef.current(-1)); + return () => backButton.hide(); + }, [capabilities.hasBackButton, backButton]); + + const { + data: page, + isLoading, + isError, + } = useQuery({ + queryKey: ['info-pages', 'page', slug], + queryFn: () => { + if (!slug) throw new Error('Missing slug parameter'); + return infoPagesApi.getPageBySlug(slug); + }, + enabled: !!slug, + staleTime: 60_000, + }); + + const locale = i18n.language.split('-')[0]; + + const resolvedTitle = useMemo(() => { + if (!page) return ''; + return page.title[locale] || page.title['ru'] || page.title['en'] || ''; + }, [page, locale]); + + // Content is sanitized with DOMPurify before rendering + const sanitizedContent = useMemo(() => { + if (!page) return ''; + const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || ''; + return sanitizeHtml(rawContent); + }, [page, locale]); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (isError || !page) { + return ( +
+ {!capabilities.hasBackButton && ( + + )} +
+ {t('admin.infoPages.notFound')} +
+
+ ); + } + + return ( +
+ {/* Back button */} + {!capabilities.hasBackButton && ( + + )} + + {/* Page header */} +
+ {page.icon && {page.icon}} +

+ {resolvedTitle} +

+
+ + {/* Page content - sanitized with DOMPurify (strict allowlist) */} +
+
+ ); +}