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:
Fringg
2026-03-23 10:49:46 +03:00
parent 5b12784ab8
commit 99fc33625e
17 changed files with 7750 additions and 70 deletions

View File

@@ -7,17 +7,23 @@ interface ToggleProps {
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
return (
<button
role="switch"
aria-checked={checked}
onClick={onChange}
disabled={disabled}
className={`relative h-6 w-12 rounded-full transition-colors ${
checked ? 'bg-accent-500' : 'bg-dark-600'
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
className={`flex min-h-[44px] items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<div
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
checked ? 'translate-x-6' : 'translate-x-0'
className={`relative h-8 w-14 rounded-full transition-colors ${
checked ? 'bg-accent-500' : 'bg-dark-600'
}`}
/>
>
<div
className={`absolute left-1 top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 ${
checked ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</div>
</button>
);
}

View File

@@ -0,0 +1,161 @@
import { useRef, useEffect } from 'react';
/**
* Reads the current --color-accent-400 CSS variable and returns [r, g, b].
* Falls back to [96, 165, 250] (default blue accent).
*/
function getAccentRGB(): [number, number, number] {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue('--color-accent-400')
.trim();
if (!raw) return [96, 165, 250];
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10));
if (parts.length >= 3 && parts.every((n) => !isNaN(n))) {
return [parts[0], parts[1], parts[2]];
}
return [96, 165, 250];
}
export default function GridBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Respect prefers-reduced-motion — render a single static frame
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animId = 0;
let time = 0;
let lastTimestamp = 0;
let isVisible = true;
// Read accent color once on mount
const [r, g, b] = getAccentRGB();
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const debouncedResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resize, 150);
};
window.addEventListener('resize', debouncedResize);
const draw = (timestamp: number) => {
if (!isVisible) {
animId = 0;
return;
}
// Use delta-time for frame-rate independent animation
if (lastTimestamp) {
const dt = (timestamp - lastTimestamp) / 1000;
time += dt * 0.18; // ~0.003 at 60fps equivalent
}
lastTimestamp = timestamp;
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
ctx.clearRect(0, 0, w, h);
const cols = 20;
const rows = 14;
const cellW = w / cols;
const cellH = h / rows;
// Vertical lines
for (let i = 0; i <= cols; i++) {
const x = i * cellW;
const wave = Math.sin(time + i * 0.3) * 2;
ctx.beginPath();
ctx.moveTo(x + wave, 0);
ctx.lineTo(x - wave, h);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.03 + Math.sin(time + i) * 0.015})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Horizontal lines
for (let j = 0; j <= rows; j++) {
const y = j * cellH;
const wave = Math.cos(time + j * 0.3) * 2;
ctx.beginPath();
ctx.moveTo(0, y + wave);
ctx.lineTo(w, y - wave);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.03 + Math.cos(time + j) * 0.015})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Glow nodes at intersections
for (let i = 0; i <= cols; i++) {
for (let j = 0; j <= rows; j++) {
const pulse = Math.sin(time * 2 + i * 0.7 + j * 0.5);
if (pulse > 0.85) {
const x = i * cellW;
const y = j * cellH;
const rad = 2 + pulse * 3;
const grad = ctx.createRadialGradient(x, y, 0, x, y, rad * 4);
grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${0.4 * pulse})`);
grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.beginPath();
ctx.arc(x, y, rad * 4, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
}
}
if (!prefersReduced) {
animId = requestAnimationFrame(draw);
}
};
// Pause animation when offscreen
const observer = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
if (isVisible && !prefersReduced && animId === 0) {
resize();
lastTimestamp = 0;
animId = requestAnimationFrame(draw);
}
},
{ threshold: 0 },
);
observer.observe(canvas);
// Seed lastTimestamp to avoid large delta on second frame
lastTimestamp = performance.now();
if (!prefersReduced) {
animId = requestAnimationFrame(draw);
} else {
draw(performance.now());
}
return () => {
cancelAnimationFrame(animId);
clearTimeout(resizeTimer);
observer.disconnect();
window.removeEventListener('resize', debouncedResize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 h-full w-full opacity-60"
aria-hidden="true"
/>
);
}

View File

@@ -0,0 +1,457 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { newsApi } from '../../api/news';
import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { cn } from '../../lib/utils';
import type { NewsListItem } from '../../types/news';
import GridBackground from './GridBackground';
// --- Animation variants ---
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
const fadeSlideUp = {
hidden: { opacity: 0, y: 24 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.6,
ease: EASE_OUT,
},
}),
};
// --- Icons ---
const ArrowIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// --- Sub-components ---
function ScanLine() {
return (
<motion.div
className="pointer-events-none absolute left-0 right-0 top-0 z-[2] h-[2px]"
style={{
background:
'linear-gradient(90deg, transparent, rgba(var(--color-accent-400), 0.5), transparent)',
}}
animate={{ y: ['0%', '2000%'] }}
transition={{
duration: 4,
ease: 'easeInOut',
repeat: Infinity,
}}
/>
);
}
interface CategoryBadgeProps {
category: string;
color: string;
className?: string;
}
function CategoryBadge({ category, color, className }: CategoryBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest',
className,
)}
style={{
color,
background: `${color}15`,
border: `1px solid ${color}30`,
}}
>
<span
className="h-1.5 w-1.5 animate-pulse rounded-full"
style={{
background: color,
boxShadow: `0 0 8px ${color}`,
}}
/>
{category}
</span>
);
}
interface TagBadgeProps {
text: string;
color: string;
}
function TagBadge({ text, color }: TagBadgeProps) {
return (
<span
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
style={{
color,
border: `1px solid ${color}33`,
background: `${color}11`,
}}
>
{text}
</span>
);
}
interface FilterTabsProps {
categories: string[];
active: string;
onChange: (category: string) => void;
}
function FilterTabs({ categories, active, onChange }: FilterTabsProps) {
const { t } = useTranslation();
const haptic = useHapticFeedback();
return (
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label={t('news.title')}>
{/* "All" tab — empty string means no filter */}
<button
role="tab"
aria-selected={active === ''}
onClick={() => {
haptic.selectionChanged();
onChange('');
}}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-xs font-semibold tracking-wide transition-all duration-300',
active === ''
? 'border border-accent-400 bg-accent-400 text-dark-950'
: 'border border-dark-700 bg-dark-800 text-dark-400 hover:border-accent-400/30 hover:text-accent-400',
)}
>
{t('news.filterAll')}
</button>
{categories.map((cat) => {
const isActive = active === cat;
return (
<button
key={cat}
role="tab"
aria-selected={isActive}
onClick={() => {
haptic.selectionChanged();
onChange(cat);
}}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-xs font-semibold tracking-wide transition-all duration-300',
isActive
? 'border border-accent-400 bg-accent-400 text-dark-950'
: 'border border-dark-700 bg-dark-800 text-dark-400 hover:border-accent-400/30 hover:text-accent-400',
)}
>
{cat}
</button>
);
})}
</div>
);
}
interface FeaturedCardProps {
item: NewsListItem;
onClick: () => void;
}
function FeaturedCard({ item, onClick }: FeaturedCardProps) {
const { t, i18n } = useTranslation();
return (
<motion.div
custom={0}
variants={fadeSlideUp}
initial="hidden"
animate="visible"
className="group col-span-full cursor-pointer rounded-2xl p-px transition-all duration-500"
style={{
background:
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.2), rgba(var(--color-dark-900), 0.2), rgba(var(--color-accent-400), 0.2))',
}}
whileHover={{
background:
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.4), rgba(var(--color-accent-500), 0.4), rgba(var(--color-accent-400), 0.4))',
}}
onClick={onClick}
>
<div className="relative flex min-h-[220px] flex-col justify-between overflow-hidden rounded-[15px] bg-dark-900 p-7 sm:p-10">
{/* Corner decoration */}
<div
className="pointer-events-none absolute right-0 top-0 h-[200px] w-[200px]"
style={{
background:
'radial-gradient(circle at top right, rgba(var(--color-accent-400), 0.08), transparent 70%)',
}}
/>
{/* Shimmer top border */}
<div
className="absolute -top-px left-[20%] right-[20%] h-px"
style={{
background:
'linear-gradient(90deg, transparent, rgba(var(--color-accent-400), 0.4), transparent)',
animation: 'newsShimmer 3s ease-in-out infinite',
}}
/>
<div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<CategoryBadge category={item.category} color={item.category_color} />
{item.tag && <TagBadge text={item.tag} color={item.category_color} />}
<span className="ml-auto font-mono text-[11px] text-dark-500">
{item.read_time_minutes} {t('news.readTime')}
</span>
</div>
<h2 className="mb-3 max-w-[700px] break-words text-2xl font-extrabold leading-tight text-dark-50 transition-colors duration-300 group-hover:text-white sm:text-[28px]">
{item.title}
</h2>
{item.excerpt && (
<p className="max-w-[600px] text-[15px] leading-relaxed text-dark-400">
{item.excerpt}
</p>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-mono text-xs text-dark-600">
{item.published_at ? new Date(item.published_at).toLocaleDateString(i18n.language) : ''}
</span>
<span className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-accent-400 transition-all duration-300 group-hover:gap-2.5">
{t('news.readMore')}
<ArrowIcon />
</span>
</div>
</div>
</motion.div>
);
}
interface NewsCardProps {
item: NewsListItem;
index: number;
onClick: () => void;
}
function NewsCard({ item, index, onClick }: NewsCardProps) {
const { t, i18n } = useTranslation();
return (
<motion.div
custom={index + 1}
variants={fadeSlideUp}
initial="hidden"
animate="visible"
className="group cursor-pointer rounded-[14px] p-px transition-all duration-[450ms]"
style={{
background:
'linear-gradient(160deg, rgba(var(--color-dark-700), 0.25), rgba(var(--color-dark-900), 0.25))',
}}
whileHover={{
y: -4,
background: `linear-gradient(160deg, ${item.category_color}55, transparent 60%)`,
}}
onClick={onClick}
>
<div className="relative flex h-full min-h-[210px] flex-col justify-between overflow-hidden rounded-[13px] bg-dark-900 p-7">
{/* Subtle corner glow on hover */}
<div
className="pointer-events-none absolute -bottom-5 -right-5 h-[100px] w-[100px] opacity-0 transition-opacity duration-500 group-hover:opacity-100"
style={{
background: `radial-gradient(circle, ${item.category_color}08, transparent 70%)`,
}}
/>
<div>
<div className="mb-3.5 flex items-center gap-2.5">
<span
className="inline-flex items-center gap-1.5 font-mono text-[10px] font-bold uppercase tracking-widest"
style={{ color: item.category_color }}
>
<span
className="h-[5px] w-[5px] rounded-full"
style={{
background: item.category_color,
boxShadow: `0 0 6px ${item.category_color}80`,
}}
/>
{item.category}
</span>
{item.tag && <TagBadge text={item.tag} color={item.category_color} />}
</div>
<h3 className="mb-2.5 break-words text-[17px] font-bold leading-snug text-dark-100 transition-colors duration-300 group-hover:text-white">
{item.title}
</h3>
{item.excerpt && (
<p className="text-[13px] leading-relaxed text-dark-400">{item.excerpt}</p>
)}
</div>
<div className="mt-5 flex items-center justify-between border-t border-dark-700/50 pt-3.5">
<span className="font-mono text-[11px] text-dark-600">
{item.published_at ? new Date(item.published_at).toLocaleDateString(i18n.language) : ''}
</span>
<span className="font-mono text-[11px] text-dark-500">
{item.read_time_minutes} {t('news.readTime')}
</span>
</div>
</div>
</motion.div>
);
}
// --- Main Component ---
const NEWS_LIMIT = 6;
export default function NewsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const haptic = useHapticFeedback();
const [filter, setFilter] = useState<string>('');
const [limit, setLimit] = useState(NEWS_LIMIT);
const categoryParam = filter || undefined;
const { data, isLoading } = useQuery({
queryKey: ['news', 'list', categoryParam, limit],
queryFn: () => newsApi.getNews({ category: categoryParam, limit, offset: 0 }),
staleTime: 60_000,
});
const items = data?.items ?? [];
const total = data?.total ?? 0;
const categories = data?.categories ?? [];
const featured = items.find((n) => n.is_featured);
const regular = items.filter((n) => !n.is_featured);
const handleCardClick = useCallback(
(slug: string) => {
haptic.buttonPress();
navigate(`/news/${slug}`);
},
[haptic, navigate],
);
const handleLoadMore = useCallback(() => {
haptic.buttonPress();
setLimit((prev) => prev + NEWS_LIMIT);
}, [haptic]);
const handleFilterChange = useCallback((category: string) => {
setFilter(category);
setLimit(NEWS_LIMIT);
}, []);
// Don't render if no news and not loading
if (!isLoading && items.length === 0) {
return null;
}
return (
<section className="relative -mx-4 overflow-hidden rounded-2xl bg-dark-950 lg:-mx-6">
<GridBackground />
<ScanLine />
<div className="relative z-[1] px-5 py-8 sm:px-6 sm:py-10">
{/* Header */}
<motion.div
variants={fadeSlideUp}
custom={0}
initial="hidden"
animate="visible"
className="mb-8"
>
<div className="mb-2 flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-400 to-accent-600 font-mono text-sm font-extrabold text-dark-950">
N
</div>
<span className="font-mono text-[11px] font-bold uppercase tracking-[0.18em] text-dark-500">
{t('news.title')}
</span>
</div>
<h2 className="mb-5 text-2xl font-extrabold leading-tight text-dark-50 sm:text-[34px]">
{t('news.title')}
</h2>
{categories.length > 0 && (
<FilterTabs categories={categories} active={filter} onChange={handleFilterChange} />
)}
</motion.div>
{/* Loading skeleton */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={cn(
'animate-pulse rounded-2xl bg-dark-900 p-7',
i === 0 && 'col-span-full',
)}
>
<div className="mb-4 h-4 w-24 rounded bg-dark-800" />
<div className="mb-3 h-6 w-3/4 rounded bg-dark-800" />
<div className="h-4 w-1/2 rounded bg-dark-800" />
</div>
))}
</div>
)}
{/* Grid */}
{!isLoading && items.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{featured && (
<FeaturedCard item={featured} onClick={() => handleCardClick(featured.slug)} />
)}
{regular.map((item, i) => (
<NewsCard
key={item.id}
item={item}
index={i}
onClick={() => handleCardClick(item.slug)}
/>
))}
</div>
)}
{/* Load more */}
{!isLoading && items.length < total && (
<motion.div
variants={fadeSlideUp}
custom={6}
initial="hidden"
animate="visible"
className="mt-10 text-center"
>
<button
onClick={handleLoadMore}
className="rounded-xl border border-dark-700 bg-transparent px-8 py-3 text-[13px] font-semibold tracking-wide text-dark-400 transition-all duration-300 hover:border-accent-400/30 hover:text-accent-400"
>
{t('news.loadMore')}
</button>
</motion.div>
)}
</div>
</section>
);
}