mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat: add news section with admin editor and public article view
- NewsSection with animated grid background, category filters, featured cards - AdminNews list page with toggle publish/featured, pagination - AdminNewsCreate with TipTap rich text editor, URL validation - NewsArticle detail page with DOMPurify sanitization, Telegram back button - Toggle component with WCAG 44px touch targets, role=switch - Prose styles for highlight marks and responsive iframes - i18n translations (en, ru, zh, fa)
This commit is contained in:
330
src/pages/AdminNews.tsx
Normal file
330
src/pages/AdminNews.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { newsApi } from '../api/news';
|
||||
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 { NewsListItem } from '../types/news';
|
||||
|
||||
// Icons
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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 StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NewsIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function ArticleRow({
|
||||
article,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTogglePublish,
|
||||
onToggleFeatured,
|
||||
}: {
|
||||
article: NewsListItem;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTogglePublish: () => void;
|
||||
onToggleFeatured: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
background: `${article.category_color}15`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1 w-1 rounded-full"
|
||||
style={{ background: article.category_color }}
|
||||
/>
|
||||
{article.category}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
article.is_published
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-500/20 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
{article.is_published ? t('news.admin.published') : t('news.admin.draft')}
|
||||
</span>
|
||||
{article.is_featured && (
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-[10px] font-medium text-warning-400">
|
||||
{t('news.admin.featured')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-dark-500">#{article.id}</span>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-sm font-medium text-dark-100">{article.title}</p>
|
||||
|
||||
{article.excerpt && (
|
||||
<p className="mt-1 truncate text-xs text-dark-400">{article.excerpt}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
|
||||
<span>
|
||||
{article.published_at ? new Date(article.published_at).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
<span>
|
||||
{article.read_time_minutes} {t('news.readTime')}
|
||||
</span>
|
||||
<span>
|
||||
{article.views_count} {t('news.views')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
onClick={onToggleFeatured}
|
||||
className={`min-h-[44px] min-w-[44px] rounded-lg p-2.5 transition-colors ${
|
||||
article.is_featured
|
||||
? 'text-warning-400 hover:bg-warning-500/10'
|
||||
: 'text-dark-500 hover:bg-dark-700 hover:text-dark-300'
|
||||
}`}
|
||||
title={t('news.admin.featured')}
|
||||
>
|
||||
<StarIcon filled={article.is_featured} />
|
||||
</button>
|
||||
<Toggle checked={article.is_published} onChange={onTogglePublish} />
|
||||
<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('news.admin.edit')}
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<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('news.admin.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminNews() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const confirm = useDestructiveConfirm();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin', 'news', 'list', page],
|
||||
queryFn: () => newsApi.getAdminNews({ limit, offset: page * limit }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const articles = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: newsApi.deleteArticle,
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||
},
|
||||
});
|
||||
|
||||
const togglePublishMutation = useMutation({
|
||||
mutationFn: newsApi.togglePublish,
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFeaturedMutation = useMutation({
|
||||
mutationFn: newsApi.toggleFeatured,
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const confirmed = await confirm(t('news.admin.confirmDelete'));
|
||||
if (confirmed) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('news.admin.title')}</h1>
|
||||
</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/news/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('news.admin.create')}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('news.admin.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Articles 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-8 rounded-lg bg-dark-700" />
|
||||
<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>
|
||||
) : articles.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">
|
||||
<NewsIcon />
|
||||
<p className="mt-2">{t('news.noNews')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{articles.map((article) => (
|
||||
<ArticleRow
|
||||
key={article.id}
|
||||
article={article}
|
||||
onEdit={() => navigate(`/admin/news/${article.id}/edit`)}
|
||||
onDelete={() => handleDelete(article.id)}
|
||||
onTogglePublish={() => togglePublishMutation.mutate(article.id)}
|
||||
onToggleFeatured={() => toggleFeaturedMutation.mutate(article.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="min-h-[44px] rounded-lg bg-dark-700 px-4 py-2.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<span className="text-dark-400">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="min-h-[44px] rounded-lg bg-dark-700 px-4 py-2.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
659
src/pages/AdminNewsCreate.tsx
Normal file
659
src/pages/AdminNewsCreate.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import ImageExtension from '@tiptap/extension-image';
|
||||
import LinkExtension from '@tiptap/extension-link';
|
||||
import PlaceholderExtension from '@tiptap/extension-placeholder';
|
||||
import TextAlignExtension from '@tiptap/extension-text-align';
|
||||
import UnderlineExtension from '@tiptap/extension-underline';
|
||||
import HighlightExtension from '@tiptap/extension-highlight';
|
||||
import { newsApi } from '../api/news';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { Toggle } from '../components/admin/Toggle';
|
||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { NewsCreateRequest } from '../types/news';
|
||||
|
||||
// --- Icons ---
|
||||
const BoldIcon = () => (
|
||||
<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;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={cn(
|
||||
'rounded p-2 transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-700 hover:text-dark-200',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Slug utility ---
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-а-яё]/gi, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
// --- Predefined category colors ---
|
||||
const CATEGORY_COLORS = [
|
||||
'#00e5a0',
|
||||
'#00b4d8',
|
||||
'#f72585',
|
||||
'#ffd60a',
|
||||
'#7c3aed',
|
||||
'#f97316',
|
||||
'#06b6d4',
|
||||
'#ec4899',
|
||||
];
|
||||
|
||||
export default function AdminNewsCreate() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const isEdit = !!id;
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
const [category, setCategory] = useState('');
|
||||
const [categoryColor, setCategoryColor] = useState('#00e5a0');
|
||||
const [tag, setTag] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
|
||||
const [readTimeMinutes, setReadTimeMinutes] = useState(3);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// TipTap editor — memoize extensions to avoid re-creation on every render
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
UnderlineExtension,
|
||||
LinkExtension.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { class: 'link' },
|
||||
}),
|
||||
ImageExtension.configure({
|
||||
HTMLAttributes: { class: 'rounded-xl max-w-full' },
|
||||
}),
|
||||
PlaceholderExtension.configure({
|
||||
placeholder: t('news.admin.contentLabel'),
|
||||
}),
|
||||
TextAlignExtension.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
HighlightExtension,
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch existing categories for suggestions
|
||||
const { data: newsData } = useQuery({
|
||||
queryKey: ['admin', 'news', 'categories'],
|
||||
queryFn: () => newsApi.getAdminNews({ limit: 1 }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const existingCategories = newsData?.categories ?? [];
|
||||
|
||||
// Fetch article for editing
|
||||
const { data: articleData, isLoading: isLoadingArticle } = useQuery({
|
||||
queryKey: ['admin', 'news', 'article', id],
|
||||
queryFn: () => newsApi.getAdminArticle(Number(id)),
|
||||
enabled: isEdit,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Populate form when article data loads — guard prevents re-populating on editor re-init
|
||||
const editorPopulated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!articleData) return;
|
||||
setTitle(articleData.title);
|
||||
setSlug(articleData.slug);
|
||||
setSlugManuallyEdited(true);
|
||||
setCategory(articleData.category);
|
||||
setCategoryColor(articleData.category_color);
|
||||
setTag(articleData.tag ?? '');
|
||||
setExcerpt(articleData.excerpt ?? '');
|
||||
setFeaturedImageUrl(articleData.featured_image_url ?? '');
|
||||
setReadTimeMinutes(articleData.read_time_minutes);
|
||||
setIsPublished(articleData.is_published);
|
||||
setIsFeatured(articleData.is_featured);
|
||||
if (editor && articleData.content && !editorPopulated.current) {
|
||||
editor.commands.setContent(articleData.content);
|
||||
editorPopulated.current = true;
|
||||
}
|
||||
}, [articleData, editor]);
|
||||
|
||||
// Auto-generate slug from title
|
||||
useEffect(() => {
|
||||
if (!slugManuallyEdited && title) {
|
||||
setSlug(generateSlug(title));
|
||||
}
|
||||
}, [title, slugManuallyEdited]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: NewsCreateRequest) => {
|
||||
if (isEdit) {
|
||||
return newsApi.updateArticle(Number(id), data);
|
||||
}
|
||||
return newsApi.createArticle(data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||
navigate('/admin/news');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
haptic.error();
|
||||
setSaveError(error.message || t('news.admin.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setSaveError(null);
|
||||
if (!title.trim() || !slug.trim() || !category.trim()) return;
|
||||
|
||||
const content = editor?.getHTML() ?? '';
|
||||
const data: NewsCreateRequest = {
|
||||
title: title.trim(),
|
||||
slug: slug.trim(),
|
||||
content,
|
||||
excerpt: excerpt.trim() || null,
|
||||
category: category.trim(),
|
||||
category_color: categoryColor,
|
||||
tag: tag.trim() || null,
|
||||
featured_image_url: featuredImageUrl.trim() || null,
|
||||
is_published: isPublished,
|
||||
is_featured: isFeatured,
|
||||
read_time_minutes: readTimeMinutes,
|
||||
};
|
||||
|
||||
haptic.buttonPress();
|
||||
saveMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Toolbar actions
|
||||
const addImage = () => {
|
||||
const url = window.prompt(t('news.admin.toolbar.imageUrlPrompt'));
|
||||
if (url && editor) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
|
||||
if (url && editor) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoadingArticle) {
|
||||
return (
|
||||
<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/news" />
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{isEdit ? t('news.admin.edit') : t('news.admin.create')}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !category.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('news.admin.saving') : t('news.admin.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="label">{t('news.admin.titleLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="label">{t('news.admin.slugLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setSlugManuallyEdited(true);
|
||||
}}
|
||||
className="input font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + color row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">{t('news.admin.categoryLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="input"
|
||||
list="category-suggestions"
|
||||
required
|
||||
/>
|
||||
<datalist id="category-suggestions">
|
||||
{existingCategories.map((cat) => (
|
||||
<option key={cat} value={cat} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('news.admin.categoryColorLabel')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryColor}
|
||||
onChange={(e) => setCategoryColor(e.target.value)}
|
||||
className="input flex-1 font-mono text-sm"
|
||||
/>
|
||||
<div
|
||||
className="h-10 w-10 shrink-0 rounded-lg border border-dark-700"
|
||||
style={{ background: categoryColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{CATEGORY_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setCategoryColor(color)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-lg border-2 transition-all',
|
||||
categoryColor === color ? 'scale-110 border-white' : 'border-transparent',
|
||||
)}
|
||||
style={{ background: color }}
|
||||
aria-label={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag + Read time row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">{t('news.admin.tagLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
className="input font-mono text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('news.admin.readTimeLabel')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={readTimeMinutes}
|
||||
onChange={(e) => setReadTimeMinutes(Number(e.target.value) || 1)}
|
||||
min={1}
|
||||
max={60}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div>
|
||||
<label className="label">{t('news.admin.excerptLabel')}</label>
|
||||
<textarea
|
||||
value={excerpt}
|
||||
onChange={(e) => setExcerpt(e.target.value)}
|
||||
className="input min-h-[80px] resize-y"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Featured Image URL */}
|
||||
<div>
|
||||
<label className="label">{t('news.admin.imageLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={featuredImageUrl}
|
||||
onChange={(e) => setFeaturedImageUrl(e.target.value)}
|
||||
className="input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{featuredImageUrl && (
|
||||
<div className="mt-2 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={featuredImageUrl}
|
||||
alt=""
|
||||
className="h-auto max-h-48 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggles row */}
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={isPublished} onChange={() => setIsPublished((v) => !v)} />
|
||||
<span className="text-sm text-dark-300">{t('news.admin.published')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={isFeatured} onChange={() => setIsFeatured((v) => !v)} />
|
||||
<span className="text-sm text-dark-300">{t('news.admin.featured')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content editor */}
|
||||
<div>
|
||||
<label className="label">{t('news.admin.contentLabel')}</label>
|
||||
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50">
|
||||
{/* 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={addImage} title={t('news.admin.toolbar.image')}>
|
||||
<ImageIcon />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor content */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</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 for long forms */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !category.trim()}
|
||||
className="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('news.admin.saving') : t('news.admin.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance';
|
||||
import { wheelApi } from '../api/wheel';
|
||||
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||
import PromoOffersSection from '../components/PromoOffersSection';
|
||||
import NewsSection from '../components/news/NewsSection';
|
||||
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
||||
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||
@@ -335,6 +336,9 @@ export default function Dashboard() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* News Section */}
|
||||
<NewsSection />
|
||||
|
||||
{/* Onboarding Tutorial */}
|
||||
{showOnboarding && (
|
||||
<Onboarding
|
||||
|
||||
258
src/pages/NewsArticle.tsx
Normal file
258
src/pages/NewsArticle.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { newsApi } from '../api/news';
|
||||
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>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content using DOMPurify to prevent XSS attacks.
|
||||
* All article content from the API is sanitized before rendering.
|
||||
*/
|
||||
const ALLOWED_IFRAME_HOSTS = [
|
||||
'www.youtube.com',
|
||||
'youtube.com',
|
||||
'player.vimeo.com',
|
||||
'www.youtube-nocookie.com',
|
||||
];
|
||||
|
||||
// Register DOMPurify hook to restrict iframe src to trusted hosts
|
||||
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
||||
if (data.tagName === 'iframe') {
|
||||
const el = node as Element;
|
||||
const src = el.getAttribute?.('src') ?? '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
if (!ALLOWED_IFRAME_HOSTS.includes(url.hostname)) {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} catch {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['allowfullscreen', 'frameborder', 'src', 'allow'],
|
||||
});
|
||||
}
|
||||
|
||||
export default function NewsArticlePage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { capabilities, backButton } = usePlatform();
|
||||
|
||||
// Show Telegram native back button (use ref to avoid effect re-runs)
|
||||
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: article,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['news', 'article', slug],
|
||||
queryFn: () => newsApi.getArticle(slug!),
|
||||
enabled: !!slug,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sanitizedContent = useMemo(() => (article ? sanitizeHtml(article.content) : ''), [article]);
|
||||
|
||||
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-5 w-48 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 || !article) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
aria-label={t('news.backToHome')}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
{t('news.noNews')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex h-10 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('news.backToHome')}
|
||||
>
|
||||
<BackIcon />
|
||||
<span>{t('news.backToHome')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Article header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{/* Category + tag */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
background: `${article.category_color}15`,
|
||||
border: `1px solid ${article.category_color}30`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||
style={{
|
||||
background: article.category_color,
|
||||
boxShadow: `0 0 8px ${article.category_color}`,
|
||||
}}
|
||||
/>
|
||||
{article.category}
|
||||
</span>
|
||||
{article.tag && (
|
||||
<span
|
||||
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||
style={{
|
||||
color: article.category_color,
|
||||
border: `1px solid ${article.category_color}33`,
|
||||
background: `${article.category_color}11`,
|
||||
}}
|
||||
>
|
||||
{article.tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-4 text-2xl font-extrabold leading-tight text-dark-50 sm:text-3xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 text-sm text-dark-400">
|
||||
{article.published_at && (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-xs">
|
||||
<CalendarIcon />
|
||||
{new Date(article.published_at).toLocaleDateString(i18n.language)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-xs">
|
||||
<ClockIcon />
|
||||
{article.read_time_minutes} {t('news.readTime')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-xs">
|
||||
<EyeIcon />
|
||||
{article.views_count} {t('news.views')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Featured image */}
|
||||
{article.featured_image_url && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<img
|
||||
src={article.featured_image_url}
|
||||
alt={article.title}
|
||||
className="h-auto w-full rounded-xl object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Article content - sanitized with DOMPurify */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="prose max-w-none lg:max-w-3xl"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user