mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13: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:
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