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

5509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,12 +35,23 @@
"@tanstack/react-query": "^5.8.0",
"@tanstack/react-table": "^8.21.3",
"@telegram-apps/sdk-react": "^3.3.9",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-highlight": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"autoprefixer": "^10.4.24",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"dompurify": "^3.3.3",
"framer-motion": "^12.29.2",
"graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1",
@@ -54,6 +65,7 @@
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"reactjs-tiptap-editor": "^1.0.19",
"recharts": "^3.7.0",
"sigma": "^3.0.2",
"simplex-noise": "^4.0.3",

View File

@@ -117,6 +117,11 @@ const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
// News pages
const NewsArticlePage = lazy(() => import('./pages/NewsArticle'));
const AdminNews = lazy(() => import('./pages/AdminNews'));
const AdminNewsCreate = lazy(() => import('./pages/AdminNewsCreate'));
function ProtectedRoute({
children,
withLayout = true,
@@ -478,6 +483,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/news/:slug"
element={
<ProtectedRoute>
<LazyPage>
<NewsArticlePage />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
@@ -1162,6 +1177,38 @@ function App() {
</PermissionRoute>
}
/>
{/* News admin routes */}
<Route
path="/admin/news"
element={
<PermissionRoute permission="news:read">
<LazyPage>
<AdminNews />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/news/create"
element={
<PermissionRoute permission="news:create">
<LazyPage>
<AdminNewsCreate />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/news/:id/edit"
element={
<PermissionRoute permission="news:edit">
<LazyPage>
<AdminNewsCreate />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/audit-log"
element={

59
src/api/news.ts Normal file
View File

@@ -0,0 +1,59 @@
import apiClient from './client';
import type {
NewsArticle,
NewsListResponse,
NewsCreateRequest,
NewsUpdateRequest,
} from '../types/news';
export const newsApi = {
// User endpoints
getNews: async (params?: {
category?: string;
limit?: number;
offset?: number;
}): Promise<NewsListResponse> => {
const response = await apiClient.get<NewsListResponse>('/cabinet/news', { params });
return response.data;
},
getArticle: async (slug: string): Promise<NewsArticle> => {
const response = await apiClient.get<NewsArticle>(`/cabinet/news/${slug}`);
return response.data;
},
// Admin endpoints
getAdminNews: async (params?: { limit?: number; offset?: number }): Promise<NewsListResponse> => {
const response = await apiClient.get<NewsListResponse>('/cabinet/admin/news', { params });
return response.data;
},
getAdminArticle: async (id: number): Promise<NewsArticle> => {
const response = await apiClient.get<NewsArticle>(`/cabinet/admin/news/${id}`);
return response.data;
},
createArticle: async (data: NewsCreateRequest): Promise<NewsArticle> => {
const response = await apiClient.post<NewsArticle>('/cabinet/admin/news', data);
return response.data;
},
updateArticle: async (id: number, data: NewsUpdateRequest): Promise<NewsArticle> => {
const response = await apiClient.put<NewsArticle>(`/cabinet/admin/news/${id}`, data);
return response.data;
},
deleteArticle: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/news/${id}`);
},
togglePublish: async (id: number): Promise<NewsArticle> => {
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/publish`);
return response.data;
},
toggleFeatured: async (id: number): Promise<NewsArticle> => {
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/feature`);
return response.data;
},
};

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>
);
}

View File

@@ -4473,5 +4473,59 @@
"shareModalActivateViaCabinet": "Or via website:",
"copyMessage": "Copy message",
"shareToastCopied": "Message copied"
},
"news": {
"title": "News & Updates",
"filterAll": "All",
"readMore": "Read more",
"readTime": "min read",
"loadMore": "Load more",
"noNews": "No news yet",
"backToHome": "Back",
"views": "views",
"admin": {
"title": "News Management",
"create": "Create Article",
"edit": "Edit",
"delete": "Delete",
"confirmDelete": "Delete this article?",
"published": "Published",
"draft": "Draft",
"featured": "Featured",
"titleLabel": "Title",
"slugLabel": "URL Slug",
"categoryLabel": "Category",
"categoryColorLabel": "Category Color",
"tagLabel": "Tag",
"excerptLabel": "Short Description",
"contentLabel": "Content",
"imageLabel": "Featured Image",
"readTimeLabel": "Read Time (min)",
"save": "Save",
"saving": "Saving...",
"saveError": "Failed to save article. Please try again.",
"saved": "Article saved",
"deleted": "Article deleted",
"toolbar": {
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"strikethrough": "Strikethrough",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"bulletList": "Bullet List",
"orderedList": "Ordered List",
"blockquote": "Blockquote",
"codeBlock": "Code Block",
"alignLeft": "Align Left",
"alignCenter": "Align Center",
"highlight": "Highlight",
"link": "Link",
"image": "Image",
"imageUrlPrompt": "Image URL:",
"linkUrlPrompt": "URL:"
}
}
}
}

View File

@@ -3921,5 +3921,59 @@
"warning": {
"telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
}
},
"news": {
"title": "اخبار و به‌روزرسانی‌ها",
"filterAll": "همه",
"readMore": "ادامه مطلب",
"readTime": "دقیقه مطالعه",
"loadMore": "بارگذاری بیشتر",
"noNews": "هنوز خبری نیست",
"backToHome": "بازگشت",
"views": "بازدید",
"admin": {
"title": "مدیریت اخبار",
"create": "ایجاد مقاله",
"edit": "ویرایش",
"delete": "حذف",
"confirmDelete": "این مقاله حذف شود؟",
"published": "منتشر شده",
"draft": "پیش‌نویس",
"featured": "ویژه",
"titleLabel": "عنوان",
"slugLabel": "شناسه URL",
"categoryLabel": "دسته‌بندی",
"categoryColorLabel": "رنگ دسته‌بندی",
"tagLabel": "برچسب",
"excerptLabel": "توضیح کوتاه",
"contentLabel": "محتوا",
"imageLabel": "تصویر ویژه",
"readTimeLabel": "زمان مطالعه (دقیقه)",
"save": "ذخیره",
"saving": "در حال ذخیره...",
"saveError": "ذخیره مقاله ناموفق بود. لطفاً دوباره تلاش کنید.",
"saved": "مقاله ذخیره شد",
"deleted": "مقاله حذف شد",
"toolbar": {
"bold": "پررنگ",
"italic": "کج",
"underline": "زیرخط",
"strikethrough": "خط‌خورده",
"heading1": "عنوان ۱",
"heading2": "عنوان ۲",
"heading3": "عنوان ۳",
"bulletList": "لیست نقطه‌ای",
"orderedList": "لیست شماره‌دار",
"blockquote": "نقل قول",
"codeBlock": "بلوک کد",
"alignLeft": "چپ‌چین",
"alignCenter": "وسط‌چین",
"highlight": "برجسته",
"link": "پیوند",
"image": "تصویر",
"imageUrlPrompt": "آدرس تصویر:",
"linkUrlPrompt": "آدرس پیوند:"
}
}
}
}

View File

@@ -5040,5 +5040,59 @@
"shareModalActivateViaCabinet": "Или через сайт:",
"copyMessage": "Скопировать сообщение",
"shareToastCopied": "Сообщение скопировано"
},
"news": {
"title": "Новости и обновления",
"filterAll": "Все",
"readMore": "Читать подробнее",
"readTime": "мин чтения",
"loadMore": "Загрузить ещё",
"noNews": "Пока нет новостей",
"backToHome": "Назад",
"views": "просмотров",
"admin": {
"title": "Управление новостями",
"create": "Создать новость",
"edit": "Редактировать",
"delete": "Удалить",
"confirmDelete": "Удалить эту новость?",
"published": "Опубликовано",
"draft": "Черновик",
"featured": "Избранное",
"titleLabel": "Заголовок",
"slugLabel": "URL-адрес",
"categoryLabel": "Категория",
"categoryColorLabel": "Цвет категории",
"tagLabel": "Тег",
"excerptLabel": "Краткое описание",
"contentLabel": "Содержание",
"imageLabel": "Изображение",
"readTimeLabel": "Время чтения (мин)",
"save": "Сохранить",
"saving": "Сохранение...",
"saveError": "Не удалось сохранить статью. Попробуйте ещё раз.",
"saved": "Новость сохранена",
"deleted": "Новость удалена",
"toolbar": {
"bold": "Жирный",
"italic": "Курсив",
"underline": "Подчёркнутый",
"strikethrough": "Зачёркнутый",
"heading1": "Заголовок 1",
"heading2": "Заголовок 2",
"heading3": "Заголовок 3",
"bulletList": "Маркированный список",
"orderedList": "Нумерованный список",
"blockquote": "Цитата",
"codeBlock": "Блок кода",
"alignLeft": "По левому краю",
"alignCenter": "По центру",
"highlight": "Выделение",
"link": "Ссылка",
"image": "Изображение",
"imageUrlPrompt": "URL изображения:",
"linkUrlPrompt": "URL ссылки:"
}
}
}
}

View File

@@ -3920,5 +3920,59 @@
"warning": {
"telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
}
},
"news": {
"title": "新闻与更新",
"filterAll": "全部",
"readMore": "阅读更多",
"readTime": "分钟阅读",
"loadMore": "加载更多",
"noNews": "暂无新闻",
"backToHome": "返回",
"views": "浏览量",
"admin": {
"title": "新闻管理",
"create": "创建文章",
"edit": "编辑",
"delete": "删除",
"confirmDelete": "删除这篇文章?",
"published": "已发布",
"draft": "草稿",
"featured": "精选",
"titleLabel": "标题",
"slugLabel": "URL标识",
"categoryLabel": "分类",
"categoryColorLabel": "分类颜色",
"tagLabel": "标签",
"excerptLabel": "简短描述",
"contentLabel": "内容",
"imageLabel": "特色图片",
"readTimeLabel": "阅读时间(分钟)",
"save": "保存",
"saving": "保存中...",
"saveError": "保存文章失败,请重试。",
"saved": "文章已保存",
"deleted": "文章已删除",
"toolbar": {
"bold": "粗体",
"italic": "斜体",
"underline": "下划线",
"strikethrough": "删除线",
"heading1": "标题 1",
"heading2": "标题 2",
"heading3": "标题 3",
"bulletList": "无序列表",
"orderedList": "有序列表",
"blockquote": "引用",
"codeBlock": "代码块",
"alignLeft": "左对齐",
"alignCenter": "居中",
"highlight": "高亮",
"link": "链接",
"image": "图片",
"imageUrlPrompt": "图片URL",
"linkUrlPrompt": "URL"
}
}
}
}

330
src/pages/AdminNews.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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>
);
}

View File

@@ -981,6 +981,14 @@ img.twemoji {
@apply my-4 max-w-full rounded-xl;
}
.prose mark {
@apply rounded bg-warning-500/30 px-1 text-dark-100;
}
.prose iframe {
@apply my-4 block aspect-video w-full max-w-2xl rounded-xl;
}
/* Light theme prose styles */
.light .prose {
@apply text-champagne-800;
@@ -1054,6 +1062,14 @@ img.twemoji {
@apply border-champagne-300 text-champagne-700;
}
.light .prose mark {
@apply bg-warning-400/30 text-champagne-900;
}
.light .prose iframe {
@apply border border-champagne-300;
}
/* Support for plain text with line breaks */
.prose-plain {
@apply whitespace-pre-line;
@@ -1522,6 +1538,19 @@ input[type='checkbox']:hover:not(:checked) {
}
}
/* News section shimmer */
@keyframes newsShimmer {
0%,
100% {
opacity: 0.3;
transform: scaleX(0.5);
}
50% {
opacity: 1;
transform: scaleX(1);
}
}
/* User preference: reduce motion */
.reduce-motion,
.reduce-motion * {

59
src/types/news.ts Normal file
View File

@@ -0,0 +1,59 @@
export interface NewsArticle {
id: number;
title: string;
slug: string;
content: string;
excerpt: string | null;
category: string;
category_color: string;
tag: string | null;
featured_image_url: string | null;
is_published: boolean;
is_featured: boolean;
published_at: string | null;
read_time_minutes: number;
views_count: number;
author_name?: string | null;
created_at: string;
updated_at: string | null;
}
export interface NewsListItem {
id: number;
title: string;
slug: string;
excerpt: string | null;
category: string;
category_color: string;
tag: string | null;
featured_image_url: string | null;
is_published: boolean;
is_featured: boolean;
published_at: string | null;
read_time_minutes: number;
views_count: number;
}
export interface NewsListResponse {
items: NewsListItem[];
total: number;
categories: string[];
}
export interface NewsCreateRequest {
title: string;
slug: string;
content: string;
excerpt: string | null;
category: string;
category_color: string;
tag: string | null;
featured_image_url: string | null;
is_published: boolean;
is_featured: boolean;
read_time_minutes: number;
}
export interface NewsUpdateRequest extends Partial<NewsCreateRequest> {
id?: never;
}