import { useState, useCallback, memo } 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'; import { PlusIcon, RefreshIcon, PencilIcon, TrashIcon, StarIcon, NewsIcon, } from '@/components/icons'; // --- Security: hex color validation to prevent CSS injection --- const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function safeColor(color: string | null | undefined, fallback = '#888888'): string { if (!color || !HEX_COLOR_RE.test(color)) return fallback; return color; } // memo: prevents rows from re-rendering when sibling rows or parent state change const ArticleRow = memo(function ArticleRow({ article, onEdit, onDelete, onTogglePublish, onToggleFeatured, }: { article: NewsListItem; onEdit: () => void; onDelete: () => void; onTogglePublish: () => void; onToggleFeatured: () => void; }) { const { t } = useTranslation(); const color = safeColor(article.category_color); return (
{article.category} {article.is_published ? t('news.admin.published') : t('news.admin.draft')} {article.is_featured && ( {t('news.admin.featured')} )} #{article.id}

{article.title}

{article.excerpt && (

{article.excerpt}

)}
{article.published_at ? new Date(article.published_at).toLocaleDateString() : '-'} {article.read_time_minutes} {t('news.readTime')} {article.views_count} {t('news.views')}
); }); // Thin wrapper that provides stable per-row callbacks so ArticleRow (memo'd) // does not re-render on every parent render due to new inline lambdas. interface ArticleRowWrapperProps { article: NewsListItem; onNavigate: (path: string) => void; onDelete: (id: number) => void; onTogglePublish: (id: number) => void; onToggleFeatured: (id: number) => void; } const ArticleRowWrapper = memo(function ArticleRowWrapper({ article, onNavigate, onDelete, onTogglePublish, onToggleFeatured, }: ArticleRowWrapperProps) { const handleEdit = useCallback( () => onNavigate(`/admin/news/${article.id}/edit`), [article.id, onNavigate], ); const handleDelete = useCallback(() => onDelete(article.id), [article.id, onDelete]); const handleTogglePublish = useCallback( () => onTogglePublish(article.id), [article.id, onTogglePublish], ); const handleToggleFeatured = useCallback( () => onToggleFeatured(article.id), [article.id, onToggleFeatured], ); return ( ); }); 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'] }); queryClient.invalidateQueries({ queryKey: ['news'] }); }, }); const togglePublishMutation = useMutation({ mutationFn: newsApi.togglePublish, onSuccess: () => { haptic.success(); queryClient.invalidateQueries({ queryKey: ['admin', 'news'] }); queryClient.invalidateQueries({ queryKey: ['news'] }); }, }); const toggleFeaturedMutation = useMutation({ mutationFn: newsApi.toggleFeatured, onSuccess: () => { haptic.success(); queryClient.invalidateQueries({ queryKey: ['admin', 'news'] }); queryClient.invalidateQueries({ queryKey: ['news'] }); }, }); // Stable callbacks passed to ArticleRowWrapper — reference equality is // required for memo to prevent unnecessary re-renders of each row. const handleDelete = useCallback( async (id: number) => { const confirmed = await confirm(t('news.admin.confirmDelete')); if (confirmed) { deleteMutation.mutate(id); } }, [confirm, deleteMutation, t], ); const handleTogglePublish = useCallback( (id: number) => { togglePublishMutation.mutate(id); }, [togglePublishMutation], ); const handleToggleFeatured = useCallback( (id: number) => { toggleFeaturedMutation.mutate(id); }, [toggleFeaturedMutation], ); return (
{/* Header */}

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

{/* Articles list */} {isLoading ? (
{Array.from({ length: 3 }).map((_, i) => (
))}
) : articles.length === 0 ? (

{t('news.noNews')}

) : (
{articles.map((article) => ( ))}
)} {/* Pagination */} {totalPages > 1 && (
{page + 1} / {totalPages}
)}
); }