mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: information pages — admin editor with TipTap, public viewer
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
This commit is contained in:
47
src/App.tsx
47
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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/info/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<InfoPageView />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
@@ -1281,6 +1296,38 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Info pages admin routes */}
|
||||
<Route
|
||||
path="/admin/info-pages"
|
||||
element={
|
||||
<PermissionRoute permission="settings:read">
|
||||
<LazyPage>
|
||||
<AdminInfoPages />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/info-pages/create"
|
||||
element={
|
||||
<PermissionRoute permission="settings:edit">
|
||||
<LazyPage>
|
||||
<AdminInfoPageEditor />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/info-pages/:id/edit"
|
||||
element={
|
||||
<PermissionRoute permission="settings:edit">
|
||||
<LazyPage>
|
||||
<AdminInfoPageEditor />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/audit-log"
|
||||
element={
|
||||
|
||||
96
src/api/infoPages.ts
Normal file
96
src/api/infoPages.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface InfoPage {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: Record<string, string>;
|
||||
content: Record<string, string>;
|
||||
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<string, string>;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface InfoPageCreateRequest {
|
||||
slug: string;
|
||||
title: Record<string, string>;
|
||||
content: Record<string, string>;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface InfoPageUpdateRequest {
|
||||
slug?: string;
|
||||
title?: Record<string, string>;
|
||||
content?: Record<string, string>;
|
||||
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<InfoPageListItem[]> => {
|
||||
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/info-pages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPageBySlug: async (slug: string): Promise<InfoPage> => {
|
||||
const response = await apiClient.get<InfoPage>(
|
||||
`/cabinet/info-pages/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
getAdminPages: async (): Promise<InfoPageListItem[]> => {
|
||||
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/admin/info-pages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAdminPage: async (id: number): Promise<InfoPage> => {
|
||||
const response = await apiClient.get<InfoPage>(`/cabinet/admin/info-pages/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPage: async (data: InfoPageCreateRequest): Promise<InfoPage> => {
|
||||
const response = await apiClient.post<InfoPage>('/cabinet/admin/info-pages', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePage: async (id: number, data: InfoPageUpdateRequest): Promise<InfoPage> => {
|
||||
const response = await apiClient.put<InfoPage>(`/cabinet/admin/info-pages/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deletePage: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/info-pages/${id}`);
|
||||
},
|
||||
|
||||
toggleActive: async (id: number): Promise<InfoPage> => {
|
||||
const response = await apiClient.post<InfoPage>(
|
||||
`/cabinet/admin/info-pages/${id}/toggle-active`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reorder: async (data: InfoPageReorderRequest): Promise<void> => {
|
||||
await apiClient.post('/cabinet/admin/info-pages/reorder', data);
|
||||
},
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
833
src/pages/AdminInfoPageEditor.tsx
Normal file
833
src/pages/AdminInfoPageEditor.tsx
Normal file
@@ -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 = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ItalicIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UnderlineIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StrikeIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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>;
|
||||
|
||||
const ListBulletIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ListOrderedIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const QuoteIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CodeBlockIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ImageIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlignLeftIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlignCenterIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const HighlightIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.56 3.44a1.5 1.5 0 012.12 0l1.88 1.88a1.5 1.5 0 010 2.12L8.44 19.56 3 21l1.44-5.44L16.56 3.44z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
// 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 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 === '<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>;
|
||||
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 === '<p></p>' || 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 (
|
||||
<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 className="label">{t('admin.infoPages.fields.slug')}</label>
|
||||
<input
|
||||
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 className="label">{t('admin.infoPages.fields.icon')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
className="input"
|
||||
placeholder="📄"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('admin.infoPages.fields.sortOrder')}</label>
|
||||
<input
|
||||
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>
|
||||
|
||||
{/* Locale tabs */}
|
||||
<div>
|
||||
<label className="label">{t('admin.infoPages.localeLabel')}</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_LOCALES.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
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 className="label">
|
||||
{t('admin.infoPages.fields.title')} ({t(`admin.infoPages.locales.${activeLocale}`)})
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={titles[activeLocale] ?? ''}
|
||||
onChange={(e) => setTitles((prev) => ({ ...prev, [activeLocale]: e.target.value }))}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content editor */}
|
||||
<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 />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
title={t('news.admin.toolbar.italic')}
|
||||
>
|
||||
<ItalicIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
isActive={editor.isActive('underline')}
|
||||
title={t('news.admin.toolbar.underline')}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
isActive={editor.isActive('strike')}
|
||||
title={t('news.admin.toolbar.strikethrough')}
|
||||
>
|
||||
<StrikeIcon />
|
||||
</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 />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title={t('news.admin.toolbar.blockquote')}
|
||||
>
|
||||
<QuoteIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title={t('news.admin.toolbar.codeBlock')}
|
||||
>
|
||||
<CodeBlockIcon />
|
||||
</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 />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title={t('news.admin.toolbar.alignCenter')}
|
||||
>
|
||||
<AlignCenterIcon />
|
||||
</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 />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
||||
<LinkIcon />
|
||||
</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 />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
346
src/pages/AdminInfoPages.tsx
Normal file
346
src/pages/AdminInfoPages.tsx
Normal file
@@ -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 = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PencilIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FileTextIcon = () => (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- 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 (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
{page.icon && <span className="text-base">{page.icon}</span>}
|
||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 font-mono text-[10px] font-medium text-dark-300">
|
||||
/{page.slug}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
page.is_active
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-500/20 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
{page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')}
|
||||
</span>
|
||||
<span className="text-xs text-dark-500">#{page.id}</span>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-sm font-medium text-dark-100">{resolvedTitle}</p>
|
||||
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
|
||||
<span>
|
||||
{t('admin.infoPages.fields.sortOrder')}: {page.sort_order}
|
||||
</span>
|
||||
{page.updated_at && <span>{new Date(page.updated_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Toggle
|
||||
checked={page.is_active}
|
||||
onChange={onToggleActive}
|
||||
aria-label={t('admin.infoPages.fields.isActive')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
title={t('admin.infoPages.edit')}
|
||||
aria-label={t('admin.infoPages.edit')}
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||
title={t('admin.infoPages.delete')}
|
||||
aria-label={t('admin.infoPages.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// --- 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 (
|
||||
<PageRow
|
||||
page={page}
|
||||
locale={locale}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleActive={handleToggleActive}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.infoPages.title')}</h1>
|
||||
{items.length > 0 && (
|
||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs font-medium text-dark-300">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"
|
||||
aria-label={t('common.refresh')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
haptic.buttonPress();
|
||||
navigate('/admin/info-pages/create');
|
||||
}}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600"
|
||||
aria-label={t('admin.infoPages.create')}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.infoPages.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pages list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="animate-pulse rounded-xl border border-dark-700 bg-dark-800/50 p-4"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-4 w-16 rounded bg-dark-700" />
|
||||
<div className="h-4 w-12 rounded bg-dark-700" />
|
||||
</div>
|
||||
<div className="h-5 w-3/4 rounded bg-dark-700" />
|
||||
<div className="h-3 w-1/2 rounded bg-dark-700" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-14 rounded-full bg-dark-700" />
|
||||
<div className="h-8 w-8 rounded-lg bg-dark-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
<FileTextIcon />
|
||||
<p className="mt-2">{t('admin.infoPages.noPages')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((page) => (
|
||||
<PageRowWrapper
|
||||
key={page.id}
|
||||
page={page}
|
||||
locale={currentLocale}
|
||||
onNavigate={navigate}
|
||||
onDelete={handleDelete}
|
||||
onToggleActive={handleToggleActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -345,6 +345,15 @@ const icons = {
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</SvgIcon>
|
||||
),
|
||||
'file-text': (
|
||||
<SvgIcon>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</SvgIcon>
|
||||
),
|
||||
chevron: (
|
||||
<SvgIcon>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
@@ -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',
|
||||
|
||||
279
src/pages/InfoPageView.tsx
Normal file
279
src/pages/InfoPageView.tsx
Normal file
@@ -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 = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="skeleton h-8 w-32 rounded-lg" />
|
||||
<div className="skeleton h-10 w-3/4 rounded-lg" />
|
||||
<div className="skeleton h-64 w-full rounded-xl" />
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-4 w-full rounded" />
|
||||
<div className="skeleton h-4 w-5/6 rounded" />
|
||||
<div className="skeleton h-4 w-4/6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/info')}
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
{t('admin.infoPages.notFound')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-xl border border-dark-700 bg-dark-800 px-4 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
<BackIcon />
|
||||
<span>{t('common.back')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page header */}
|
||||
<div>
|
||||
{page.icon && <span className="mb-2 inline-block text-3xl">{page.icon}</span>}
|
||||
<h1 className="text-2xl font-extrabold leading-tight text-dark-50 sm:text-3xl">
|
||||
{resolvedTitle}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Page content - sanitized with DOMPurify (strict allowlist) */}
|
||||
<div
|
||||
className="prose max-w-none lg:max-w-3xl"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user