This commit is contained in:
mrfelfel
2026-07-18 19:41:33 +03:30
parent 87ba0d253d
commit dfb0e857f1
158 changed files with 20278 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
---
import { getCategoryPath, getFooterPageLinks, readCms } from '../lib/cms';
const cms = await readCms();
const footerPageLinks = getFooterPageLinks();
const pathname = Astro.url.pathname;
const normalizePath = (value: string) => decodeURIComponent(value.replace(/\/+$/, '') || '/');
const normalizedPathname = normalizePath(pathname);
const isNavActive = (href: string) => {
const target = normalizePath(href);
if (target === '/') {
return normalizedPathname === '/';
}
return normalizedPathname === target || normalizedPathname.startsWith(`${target}/`);
};
const mobileNav = [
{ icon: 'home', label: 'خانه', href: '/' },
{ icon: 'article', label: 'اخبار', href: '/posts/' },
{ icon: 'insights', label: 'اقتصاد', href: getCategoryPath('اقتصاد') },
{ icon: 'explore', label: 'جهان', href: getCategoryPath('بین‌الملل') },
{ icon: 'bolt', label: 'انرژی', href: getCategoryPath('انرژی') },
].map((item) => ({ ...item, active: isNavActive(item.href) }));
---
<footer class="bg-gray-900 text-white mt-12 pt-10 pb-28 lg:pb-10">
<div class="max-w-7xl mx-auto px-4 lg:px-8">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
<div class="col-span-2 lg:col-span-1">
<div class="flex items-center gap-2 mb-3">
<img
src="/assets/logo.png"
alt={cms.settings.siteInfo.name}
class="h-12 w-auto object-contain block brightness-0 invert"
loading="lazy"
/>
</div>
<p class="text-gray-400 text-sm leading-relaxed">{cms.settings.siteInfo.description}</p>
</div>
<div>
<h4 class="font-bold mb-3 text-sm">بخش‌ها</h4>
<ul class="space-y-2 text-gray-400 text-sm">
{cms.categories.map((item) => <li><a href={getCategoryPath(item)} class="hover:text-white transition-colors">{item}</a></li>)}
</ul>
</div>
<div>
<h4 class="font-bold mb-3 text-sm">درباره ما</h4>
<ul class="space-y-2 text-gray-400 text-sm">
{footerPageLinks.map((item) => <li><a href={item.href} class="hover:text-white transition-colors">{item.label}</a></li>)}
</ul>
</div>
<div>
<h4 class="font-bold mb-3 text-sm">خبرنامه</h4>
<p class="text-gray-400 text-sm mb-3">آخرین اخبار را در ایمیل دریافت کنید</p>
<div class="flex gap-2">
<input type="email" placeholder="ایمیل شما" class="flex-1 bg-gray-800 text-white text-sm px-3 py-2 rounded-lg outline-none focus:ring-1 focus:ring-brand min-w-0" />
<button class="bg-brand text-white text-sm px-3 py-2 rounded-lg hover:bg-red-700 transition-colors flex-shrink-0">ثبت</button>
</div>
</div>
</div>
<div class="border-t border-gray-800 pt-6 text-center text-gray-500 text-xs">
© {cms.settings.siteInfo.copyright}
</div>
</div>
</footer>
<nav class="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 shadow-2xl z-50">
<div class="flex justify-around items-center py-2 px-2">
{
mobileNav.map((item) => (
<a
href={item.href}
class:list={[
'flex flex-col items-center gap-0.5 px-3 py-1 transition-colors relative',
item.active ? 'text-brand' : 'text-gray-400',
]}
>
{item.active && <span class="absolute top-0 left-1/2 -translate-x-1/2 h-1 w-6 rounded-full bg-brand"></span>}
<span class="material-icons text-2xl">{item.icon}</span>
<span class:list={['text-xs', item.active && 'font-bold']}>{item.label}</span>
</a>
))
}
</div>
</nav>

130
src/components/Header.astro Normal file
View File

@@ -0,0 +1,130 @@
---
import { getApprovedPosts, getCategoryPath, getPostBySlug, getPrimaryNav, readCms } from "../lib/cms";
const cms = await readCms();
const { breakingNews } = cms.settings;
const approvedPosts = getApprovedPosts(cms);
const breakingPosts = [
...(breakingNews.postSlugs ?? []).map((slug) => getPostBySlug(approvedPosts, slug)).filter(Boolean),
...approvedPosts.filter((post) => post.urgent),
].filter((post, index, list) => list.findIndex((item) => item.slug === post.slug) === index);
const primaryNav = getPrimaryNav();
const pathname = Astro.url.pathname;
const normalizePath = (value: string) => decodeURIComponent(value.replace(/\/+$/, '') || '/');
const normalizedPathname = normalizePath(pathname);
const isActive = (href: string) => {
const target = normalizePath(href);
if (target === '/') {
return normalizedPathname === '/';
}
return normalizedPathname === target || normalizedPathname.startsWith(`${target}/`);
};
const categoryTabs = [
{ label: "همه", href: "/posts/" },
...cms.categories.map((category) => ({
label: category,
href: getCategoryPath(category),
})),
];
const breakingText = breakingPosts.length > 0
? breakingPosts.map((post) => post.title).join(' — ')
: breakingNews.items.join(' — ');
const breakingHref = breakingPosts[0] ? `/posts/${breakingPosts[0].slug}/` : breakingNews.href;
---
<header class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 lg:px-8">
<div class="flex items-center justify-between h-14 lg:h-16">
<a href="/" class="flex items-center gap-2.5 flex-shrink-0">
<img
src="/assets/logo.png"
alt={cms.settings.siteInfo.name}
class="h-24 w-auto object-contain block"
loading="eager"
/>
</a>
<nav
class="hidden lg:flex items-center gap-6 text-sm font-medium text-gray-600"
>
{
primaryNav.map((item) => (
<a href={item.href} class:list={[
'transition-colors',
isActive(item.href) ? 'text-brand font-bold' : 'text-gray-600 hover:text-brand',
]}>
{item.label}
</a>
))
}
</nav>
<div class="flex items-center gap-2">
<a
href={breakingHref}
class="hidden sm:flex items-center gap-1 bg-red-50 text-brand text-xs px-3 py-1.5 rounded-full font-medium"
>
<span class="material-icons text-sm leading-none">bolt</span>
{breakingNews.label}
</a>
<form action="/search/" method="get" class="relative">
<input
type="search"
name="q"
placeholder="جستجو..."
class="w-28 sm:w-40 lg:w-52 bg-gray-100 text-gray-700 text-sm rounded-full py-2 pr-9 pl-3 outline-none focus:ring-1 focus:ring-brand focus:bg-white transition"
/>
<button
type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-brand transition-colors"
aria-label="جستجو"
>
<span class="material-icons text-xl">search</span>
</button>
</form>
<button
class="p-2 text-gray-500 hover:text-brand transition-colors hidden sm:block"
aria-label="اعلان‌ها"
>
<span class="material-icons">notifications_none</span>
</button>
</div>
</div>
<div class="flex overflow-x-auto gap-1.5 pb-3 scrollbar-hide -mx-1 px-1">
{
categoryTabs.map((category, index) => (
<a
href={category.href}
class:list={[
"whitespace-nowrap text-xs px-3.5 py-1.5 rounded-full font-medium transition-colors",
isActive(category.href)
? "bg-brand text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
]}
>
{category.label}
</a>
))
}
</div>
</div>
</header>
<div class="bg-brand text-white">
<div class="max-w-7xl mx-auto px-4 lg:px-8 py-2.5 flex items-center gap-3">
<a
href={breakingHref}
class="bg-white text-brand text-xs font-bold px-2 py-0.5 rounded flex-shrink-0"
>{breakingNews.label}</a
>
<div class="overflow-hidden relative flex-1">
<p class="text-sm truncate">
{breakingText}
</p>
</div>
<span class="material-icons text-yellow-300 text-base flex-shrink-0"
>bolt</span
>
</div>
</div>

View File

@@ -0,0 +1,55 @@
---
type Props = {
currentPage: number;
totalPages: number;
basePath?: string;
};
const { currentPage, totalPages, basePath = '/posts/' } = Astro.props;
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
const getPageHref = (page: number) => (page === 1 ? basePath : `${basePath}?page=${page}`);
const previousPage = Math.max(1, currentPage - 1);
const nextPage = Math.min(totalPages, currentPage + 1);
---
{
totalPages > 1 && (
<>
<nav class="flex items-center justify-center gap-1.5 mt-8" aria-label="صفحه‌بندی">
{currentPage === 1 ? (
<span class="w-9 h-9 rounded-full border border-gray-200 bg-white flex items-center justify-center opacity-40" aria-disabled="true">
<span class="material-icons text-base">chevron_right</span>
</span>
) : (
<a href={getPageHref(previousPage)} class="w-9 h-9 rounded-full border border-gray-200 bg-white flex items-center justify-center transition-colors hover:bg-brand hover:text-white hover:border-brand" aria-label="صفحه قبلی">
<span class="material-icons text-base">chevron_right</span>
</a>
)}
{pages.map((page) => (
<a
href={getPageHref(page)}
aria-current={page === currentPage ? 'page' : undefined}
class:list={[
'w-9 h-9 rounded-full border border-gray-200 flex items-center justify-center text-sm font-medium transition-colors',
page === currentPage ? 'bg-brand text-white border-brand' : 'bg-white hover:bg-gray-50',
]}
>
{page}
</a>
))}
{currentPage === totalPages ? (
<span class="w-9 h-9 rounded-full border border-gray-200 bg-white flex items-center justify-center opacity-40" aria-disabled="true">
<span class="material-icons text-base">chevron_left</span>
</span>
) : (
<a href={getPageHref(nextPage)} class="w-9 h-9 rounded-full border border-gray-200 bg-white flex items-center justify-center transition-colors hover:bg-brand hover:text-white hover:border-brand" aria-label="صفحه بعدی">
<span class="material-icons text-base">chevron_left</span>
</a>
)}
</nav>
<p class="text-center text-xs text-gray-400 mt-2">صفحه {currentPage} از {totalPages}</p>
</>
)
}

View File

@@ -0,0 +1,46 @@
---
import type { Post } from '../lib/cms';
import { formatPostRelativeTime } from '../lib/date';
type Props = {
post: Post;
};
const { post } = Astro.props;
const image = post.thumbnail || post.image;
const timeLabel = formatPostRelativeTime(post);
---
<article class="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-shadow group">
<a href={`/posts/${post.slug}/`} class="flex gap-4 p-4">
<div class="img-placeholder w-28 sm:w-36 h-24 sm:h-28 rounded-xl flex-shrink-0 relative overflow-hidden">
{image ? (
<img src={image} alt={post.title} class="w-full h-full object-cover" loading="lazy" />
) : (
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
<span class="material-icons text-2xl opacity-30">image</span>
</div>
)}
</div>
<div class="flex-1 min-w-0 flex flex-col justify-between">
<div>
<span class="text-brand text-xs font-bold">{post.category}</span>
<h3 class="font-bold text-sm sm:text-base mt-1 leading-snug line-clamp-2 group-hover:text-brand transition-colors">{post.title}</h3>
<p class="text-gray-500 text-xs sm:text-sm mt-1 leading-relaxed line-clamp-2 hidden sm:block">{post.excerpt}</p>
</div>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2 text-xs text-gray-400">
<span>{timeLabel}</span>
<span>·</span>
<span>{post.readTime}</span>
<span>·</span>
<span>{post.category}</span>
</div>
<div class="flex items-center gap-1 text-gray-300">
<span class="material-icons text-lg">bookmark_border</span>
<span class="material-icons text-lg">share</span>
</div>
</div>
</div>
</a>
</article>

View File

@@ -0,0 +1,53 @@
---
type Props = {
currentPage: number;
totalPages: number;
tab: string;
pageParam: string;
};
const { currentPage, totalPages, tab, pageParam } = Astro.props;
const href = (page: number) => `/admin/?tab=${tab}&${pageParam}=${page}`;
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
const previousPage = Math.max(1, currentPage - 1);
const nextPage = Math.min(totalPages, currentPage + 1);
---
{
totalPages > 1 && (
<nav class="flex flex-wrap items-center justify-center gap-1.5 mt-6" aria-label="صفحه‌بندی">
<a
href={href(previousPage)}
aria-disabled={currentPage === 1 ? 'true' : undefined}
class:list={[
'px-3 h-8 rounded-lg border border-gray-200 bg-white flex items-center text-xs font-bold',
currentPage === 1 ? 'opacity-40 pointer-events-none' : 'hover:bg-brand hover:text-white hover:border-brand',
]}
>
قبلی
</a>
{pages.map((page) => (
<a
href={href(page)}
aria-current={page === currentPage ? 'page' : undefined}
class:list={[
'w-8 h-8 rounded-lg border flex items-center justify-center text-xs font-bold transition-colors',
page === currentPage ? 'bg-brand text-white border-brand' : 'bg-white border-gray-200 hover:bg-gray-50',
]}
>
{page}
</a>
))}
<a
href={href(nextPage)}
aria-disabled={currentPage === totalPages ? 'true' : undefined}
class:list={[
'px-3 h-8 rounded-lg border border-gray-200 bg-white flex items-center text-xs font-bold',
currentPage === totalPages ? 'opacity-40 pointer-events-none' : 'hover:bg-brand hover:text-white hover:border-brand',
]}
>
بعدی
</a>
</nav>
)
}

257
src/data/posts.ts Normal file
View File

@@ -0,0 +1,257 @@
export type Post = {
slug: string;
title: string;
category: string;
time: string;
readTime: string;
excerpt: string;
body: string[];
featured?: boolean;
};
export type LinkItem = {
label: string;
href: string;
};
export type StaticPage = {
slug: string;
title: string;
description: string;
navLabel?: string;
footerLabel?: string;
body: string[];
};
export const siteInfo = {
name: 'روس امروز',
tagline: 'خبرگزاری',
description: 'خبرگزاری تخصصی پوشش اخبار روسیه، اوراسیا و روابط منطقه‌ای',
copyright: '۱۴۰۵ روس امروز — تمامی حقوق محفوظ است',
};
export const categories = ['اقتصاد', 'سیاست', 'انرژی', 'فناوری', 'بین‌الملل'];
export const hotTopics = ['انرژی', 'تحریم', 'نفت', 'گاز', 'اوراسیا', 'SCO', 'روبل', 'تجارت', 'قفقاز', 'ناتو', 'بریکس'];
export const breakingNews = {
label: 'فوری',
href: '/search/?q=%D9%81%D9%88%D8%B1%DB%8C',
items: [
'توافق انرژی روسیه و ایران در مسکو امضا شد',
'مسکو پیشنهاد صادرات گندم بدون تعرفه به تهران را مطرح کرد',
],
};
export const staticPages: StaticPage[] = [
{
slug: 'about',
title: 'درباره ما',
description: 'آشنایی با مأموریت، رویکرد خبری و تیم تحریریه روس امروز',
body: [
'روس امروز رسانه‌ای تخصصی برای پوشش خبرها، گزارش‌ها و تحلیل‌های مرتبط با روسیه، اوراسیا و روابط منطقه‌ای است.',
'هدف ما ارائه روایت دقیق، سریع و قابل اتکا از روندهای سیاسی، اقتصادی، انرژی و فناوری در این حوزه جغرافیایی است.',
'تحریریه روس امروز تلاش می‌کند با زبان روشن و تحلیل داده‌محور، تصویر کامل‌تری از تحولات منطقه ارائه دهد.',
],
},
{
slug: 'contact',
title: 'ارتباط با ما',
description: 'راه‌های ارتباط با تحریریه و واحد بازرگانی روس امروز',
body: [
'برای ارسال خبر، پیشنهاد همکاری یا پیگیری موضوعات رسانه‌ای می‌توانید از طریق ایمیل info@ruseemrooz.ir با ما در تماس باشید.',
'پیام‌های مرتبط با آگهی و همکاری تجاری توسط واحد بازرگانی بررسی و در کوتاه‌ترین زمان پاسخ داده می‌شود.',
],
},
{
slug: 'editorial-team',
title: 'تیم تحریریه',
description: 'معرفی ساختار تحریریه و حوزه‌های تخصصی روس امروز',
footerLabel: 'تیم تحریریه',
body: [
'تیم تحریریه روس امروز از نویسندگان و تحلیلگرانی تشکیل شده که حوزه‌های روسیه، قفقاز، آسیای مرکزی، انرژی و اقتصاد منطقه‌ای را دنبال می‌کنند.',
'مطالب منتشرشده پس از بررسی محتوایی و ویرایشی در سایت قرار می‌گیرند تا دقت و خوانایی حفظ شود.',
],
},
{
slug: 'advertise',
title: 'آگهی در سایت',
description: 'فرصت‌های تبلیغاتی و همکاری رسانه‌ای با روس امروز',
body: [
'روس امروز امکان انتشار آگهی، رپورتاژ و کمپین‌های هدفمند برای مخاطبان علاقه‌مند به روسیه و اوراسیا را فراهم می‌کند.',
'برای دریافت تعرفه و پیشنهاد رسانه‌ای اختصاصی، با واحد بازرگانی سایت تماس بگیرید.',
],
},
{
slug: 'privacy',
title: 'حریم خصوصی',
description: 'سیاست حفظ حریم خصوصی کاربران روس امروز',
body: [
'روس امروز اطلاعات کاربران را تنها برای بهبود تجربه کاربری، ارسال خبرنامه و تحلیل عملکرد سایت استفاده می‌کند.',
'اطلاعات تماس کاربران بدون رضایت آن‌ها در اختیار اشخاص ثالث قرار نمی‌گیرد.',
],
},
];
export const primaryNav: LinkItem[] = [
{ label: 'صفحه اصلی', href: '/' },
...staticPages
.filter((page) => ['about', 'contact'].includes(page.slug))
.map((page) => ({ label: page.navLabel ?? page.title, href: `/${page.slug}/` })),
];
export const footerPageLinks: LinkItem[] = staticPages.map((page) => ({
label: page.footerLabel ?? page.navLabel ?? page.title,
href: `/${page.slug}/`,
}));
export const getStaticPageBySlug = (slug: string) => staticPages.find((page) => page.slug === slug);
export const getCategoryPath = (category: string) => `/categories/${encodeURIComponent(category)}/`;
export const getPostBySlug = (slug: string) => posts.find((post) => post.slug === slug);
export const getPostsByCategory = (category: string) => posts.filter((post) => post.category === category);
export const searchPosts = (query: string) => {
const normalizedQuery = query.trim().toLocaleLowerCase('fa-IR');
if (!normalizedQuery) {
return [];
}
return posts.filter((post) => {
const searchableText = [
post.title,
post.category,
post.excerpt,
...post.body,
].join(' ').toLocaleLowerCase('fa-IR');
return searchableText.includes(normalizedQuery);
});
};
export const getPaginatedPosts = (page: number, perPage: number, source = posts) => {
const totalPages = Math.max(1, Math.ceil(source.length / perPage));
const currentPage = Math.min(Math.max(Math.floor(page), 1), totalPages);
const start = (currentPage - 1) * perPage;
return {
currentPage,
totalPages,
posts: source.slice(start, start + perPage),
};
};
export const posts: Post[] = [
{
slug: 'eurasia-energy-market-transformation',
title: 'بازار انرژی اوراسیا در آستانه تحول بزرگ',
category: 'اقتصاد',
time: '۲ ساعت پیش',
readTime: '۵ دقیقه',
excerpt: 'تحلیل روندهای آینده بازار نفت و گاز در منطقه اوراسیا و پیامدهای اقتصادی برای کشورهای منطقه',
featured: true,
body: [
'بازار انرژی اوراسیا در ماه‌های اخیر وارد مرحله تازه‌ای شده و مسیرهای صادرات، قراردادهای بلندمدت و نقش بازیگران منطقه‌ای دوباره در حال بازتعریف است.',
'کارشناسان معتقدند افزایش همکاری‌های زیرساختی میان کشورهای منطقه می‌تواند هزینه انتقال انرژی را کاهش دهد و سهم تجارت منطقه‌ای را افزایش دهد.',
'در این میان، ایران و روسیه با ظرفیت‌های مکمل خود می‌توانند در حوزه گاز، نفت، برق و فناوری‌های مرتبط نقش پررنگ‌تری ایفا کنند.',
],
},
{
slug: 'iran-russia-economic-relations',
title: 'روابط اقتصادی ایران و روسیه؛ فرصت‌ها و چالش‌ها',
category: 'اقتصاد',
time: '۴ ساعت پیش',
readTime: '۴ دقیقه',
excerpt: 'در پی تحریم‌های غرب علیه روسیه، حجم تجارت دوجانبه ایران و روسیه به رکورد تاریخی رسید.',
body: [
'روابط اقتصادی ایران و روسیه در سال‌های اخیر با تمرکز بر تجارت منطقه‌ای، حمل‌ونقل و همکاری‌های مالی وارد مرحله تازه‌ای شده است.',
'با وجود فرصت‌های متعدد، محدودیت‌های بانکی، لجستیکی و نبود استانداردهای مشترک همچنان از مهم‌ترین چالش‌های این مسیر محسوب می‌شوند.',
],
},
{
slug: 'south-caucasus-geopolitics',
title: 'تحلیل ژئوپلیتیک قفقاز جنوبی',
category: 'سیاست',
time: '۶ ساعت پیش',
readTime: '۶ دقیقه',
excerpt: 'بحران قره‌باغ و تأثیر آن بر معادلات قدرت در منطقه قفقاز جنوبی مورد بررسی قرار می‌گیرد.',
body: [
'قفقاز جنوبی همچنان یکی از حساس‌ترین نقاط ژئوپلیتیک در همسایگی ایران و روسیه است.',
'تغییر موازنه قدرت در این منطقه بر کریدورهای ترانزیتی، امنیت مرزی و روابط بازیگران فرامنطقه‌ای اثر مستقیم دارد.',
],
},
{
slug: 'oil-price-new-record',
title: 'قیمت نفت در بازارهای جهانی رکورد جدید زد',
category: 'انرژی',
time: '۸ ساعت پیش',
readTime: '۴ دقیقه',
excerpt: 'افزایش تقاضای فصلی و نگرانی از محدودیت عرضه، قیمت نفت را به سطح تازه‌ای رساند.',
body: [
'بازار جهانی نفت تحت تأثیر رشد تقاضا، کاهش ذخایر تجاری و نگرانی از اختلال در عرضه با افزایش قیمت روبه‌رو شده است.',
'تحلیلگران انرژی می‌گویند مسیر قیمت‌ها در هفته‌های آینده به تصمیم تولیدکنندگان بزرگ و چشم‌انداز اقتصاد جهانی وابسته خواهد بود.',
],
},
{
slug: 'russia-china-tech-cooperation',
title: 'همکاری‌های فناوری روسیه و چین گسترش یافت',
category: 'فناوری',
time: 'دیروز',
readTime: '۳ دقیقه',
excerpt: 'مسکو و پکن پروژه مشترک توسعه تراشه‌های نیمه‌هادی را رسماً آغاز کردند.',
body: [
'روسیه و چین در ادامه همکاری‌های راهبردی خود، برنامه تازه‌ای برای توسعه فناوری‌های نیمه‌هادی و زیرساخت‌های دیجیتال آغاز کرده‌اند.',
'این همکاری می‌تواند به کاهش وابستگی فناوری به غرب و تقویت زنجیره تأمین داخلی دو کشور کمک کند.',
],
},
{
slug: 'caspian-regional-trade-report',
title: 'تجارت منطقه‌ای در دریای خزر؛ گزارش سالانه',
category: 'بین‌الملل',
time: 'دیروز',
readTime: '۸ دقیقه',
excerpt: 'گزارش سالانه اتاق بازرگانی منطقه خزر از رشد ۳۴ درصدی حجم تجارت دریایی حکایت دارد.',
body: [
'دریای خزر در سال گذشته شاهد رشد قابل توجه تجارت دریایی و افزایش جابه‌جایی کالا میان بنادر منطقه بوده است.',
'تحلیلگران این رشد را نتیجه توسعه زیرساخت‌های بندری و افزایش تقاضا برای مسیرهای جایگزین تجاری می‌دانند.',
],
},
{
slug: 'russia-inflation-three-year-low',
title: 'نرخ تورم روسیه در پایین‌ترین سطح ۳ سال اخیر',
category: 'اقتصاد',
time: '۲ روز پیش',
readTime: '۳ دقیقه',
excerpt: 'بانک مرکزی روسیه اعلام کرد تورم این کشور در ماه گذشته به ۵.۲ درصد رسیده است.',
body: [
'کاهش نرخ تورم روسیه نشانه‌ای از تثبیت نسبی سیاست‌های پولی و کنترل تقاضای داخلی ارزیابی می‌شود.',
'با این حال، نوسان قیمت انرژی و فشارهای خارجی همچنان می‌تواند مسیر تورم در ماه‌های آینده را تحت تأثیر قرار دهد.',
],
},
];
export const sideFeaturedPosts = [
{
slug: 'south-caucasus-geopolitics',
title: 'تحلیل ژئوپلیتیک قفقاز جنوبی و پیامدها',
category: 'سیاست',
time: '۶ ساعت پیش',
},
{
slug: 'oil-price-new-record',
title: 'قیمت نفت در بازارهای جهانی رکورد جدید زد',
category: 'انرژی',
time: '۸ ساعت پیش',
},
];
export const mostViewedPosts = [
{ slug: 'south-caucasus-geopolitics', title: 'تحریم‌های جدید اتحادیه اروپا علیه روسیه', category: 'سیاست' },
{ slug: 'oil-price-new-record', title: 'قرارداد گازی روسیه و ترکیه تمدید شد', category: 'انرژی' },
{ slug: 'iran-russia-economic-relations', title: 'رشد ۱۵ درصدی صادرات ایران به روسیه', category: 'اقتصاد' },
{ slug: 'russia-china-tech-cooperation', title: 'سیستم پرداخت جایگزین سوئیفت راه‌اندازی شد', category: 'فناوری' },
{ slug: 'caspian-regional-trade-report', title: 'اجلاس سران کشورهای SCO در ازبکستان', category: 'بین‌الملل' },
];

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -0,0 +1,32 @@
---
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
type Props = {
title?: string;
description?: string;
};
const {
title = 'روس امروز',
description = 'خبرگزاری تخصصی پوشش اخبار روسیه، اوراسیا و روابط منطقه‌ای',
} = Astro.props;
---
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="stylesheet" href="https://rc0.ir/material-icons/iconfont/material-icons.css" />
<link rel="stylesheet" href="https://rc0.ir/vazirmatn/Vazirmatn-font-face.css" />
</head>
<body class="bg-gray-100 font-vazir text-gray-900">
<Header />
<slot />
<Footer />
</body>
</html>

39
src/lib/adminAuth.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { APIContext } from 'astro';
const sessionCookie = 'ruseemrooz_admin';
const getAdminUser = () => import.meta.env.ADMIN_USER ?? 'admin';
const getAdminPassword = () => import.meta.env.ADMIN_PASSWORD ?? 'admin123';
const getSessionSecret = () => import.meta.env.ADMIN_SESSION_SECRET ?? 'local-session';
const encodeToken = () => Buffer.from(`${getAdminUser()}:${getSessionSecret()}`).toString('base64url');
export const isValidLogin = (username: string, password: string) => (
username === getAdminUser() && password === getAdminPassword()
);
export const setAdminSession = (context: APIContext) => {
context.cookies.set(sessionCookie, encodeToken(), {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: import.meta.env.PROD,
maxAge: 60 * 60 * 8,
});
};
export const clearAdminSession = (context: APIContext) => {
context.cookies.delete(sessionCookie, { path: '/' });
};
export const isAdminAuthenticated = (context: APIContext) => (
context.cookies.get(sessionCookie)?.value === encodeToken()
);
export const requireAdmin = (context: APIContext) => {
if (!isAdminAuthenticated(context)) {
return new Response('Unauthorized', { status: 401 });
}
return null;
};

19
src/lib/apiAuth.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { APIContext } from 'astro';
const getToken = () => import.meta.env.ADMIN_API_TOKEN ?? import.meta.env.ADMIN_PASSWORD ?? 'admin123';
export const requireApiToken = (context: APIContext) => {
const authorization = context.request.headers.get('authorization') ?? '';
const headerToken = context.request.headers.get('x-api-key') ?? '';
const bearerToken = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
const token = bearerToken || headerToken;
if (token !== getToken()) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
return null;
};

294
src/lib/cms.ts Normal file
View File

@@ -0,0 +1,294 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import fallbackCms from '../../data/cms.json';
import { derivePublishedAt } from './date';
export type PostStatus = 'approved' | 'pending';
export type Post = {
slug: string;
title: string;
category: string;
time: string;
readTime: string;
excerpt: string;
body: string[];
status: PostStatus;
image?: string;
thumbnail?: string;
urgent?: boolean;
sidebar?: boolean;
sourceUrl?: string;
publishedAt?: string;
};
export type LinkItem = {
label: string;
href: string;
};
export type StaticPage = {
slug: string;
title: string;
description: string;
navLabel?: string;
footerLabel?: string;
body: string[];
};
export type CmsData = {
settings: {
siteInfo: {
name: string;
tagline: string;
description: string;
copyright: string;
};
breakingNews: {
label: string;
href: string;
items: string[];
postSlugs?: string[];
};
hotTopics: string[];
homeLayout: {
featuredSlug: string;
sideFeaturedSlugs: string[];
latestCount: number;
showAd: boolean;
};
ad: {
enabled: boolean;
label: string;
href: string;
text: string;
};
agents?: {
tass?: {
processedLinks: string[];
};
};
};
categories: string[];
posts: Post[];
};
const dbPath = path.join(process.cwd(), 'data', 'cms.db');
const CMS_KEY = 'cms';
let dbInstance: Database.Database | null = null;
const getDb = (): Database.Database => {
if (dbInstance) {
return dbInstance;
}
const directory = path.dirname(dbPath);
if (!existsSync(directory)) {
mkdirSync(directory, { recursive: true });
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS cms (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
dbInstance = db;
return db;
};
// One-time import of the existing JSON document into SQLite on first run.
const seedIfEmpty = (db: Database.Database): void => {
const existing = db.prepare('SELECT value FROM cms WHERE key = ?').get(CMS_KEY) as
| { value: string }
| undefined;
if (existing) {
return;
}
db.prepare('INSERT INTO cms (key, value, updated_at) VALUES (?, ?, ?)').run(
CMS_KEY,
JSON.stringify(fallbackCms),
Date.now(),
);
};
export const staticPages: StaticPage[] = [
{
slug: 'about',
title: 'درباره ما',
description: 'آشنایی با مأموریت، رویکرد خبری و تیم تحریریه روس امروز',
body: [
'روس امروز رسانه‌ای تخصصی برای پوشش خبرها، گزارش‌ها و تحلیل‌های مرتبط با روسیه، اوراسیا و روابط منطقه‌ای است.',
'هدف ما ارائه روایت دقیق، سریع و قابل اتکا از روندهای سیاسی، اقتصادی، انرژی و فناوری در این حوزه جغرافیایی است.',
'تحریریه روس امروز تلاش می‌کند با زبان روشن و تحلیل داده‌محور، تصویر کامل‌تری از تحولات منطقه ارائه دهد.',
],
},
{
slug: 'contact',
title: 'ارتباط با ما',
description: 'راه‌های ارتباط با تحریریه و واحد بازرگانی روس امروز',
body: [
'برای ارسال خبر، پیشنهاد همکاری یا پیگیری موضوعات رسانه‌ای می‌توانید از طریق ایمیل info@ruseemrooz.ir با ما در تماس باشید.',
'پیام‌های مرتبط با آگهی و همکاری تجاری توسط واحد بازرگانی بررسی و در کوتاه‌ترین زمان پاسخ داده می‌شود.',
],
},
{
slug: 'editorial-team',
title: 'تیم تحریریه',
description: 'معرفی ساختار تحریریه و حوزه‌های تخصصی روس امروز',
footerLabel: 'تیم تحریریه',
body: [
'تیم تحریریه روس امروز از نویسندگان و تحلیلگرانی تشکیل شده که حوزه‌های روسیه، قفقاز، آسیای مرکزی، انرژی و اقتصاد منطقه‌ای را دنبال می‌کنند.',
'مطالب منتشرشده پس از بررسی محتوایی و ویرایشی در سایت قرار می‌گیرند تا دقت و خوانایی حفظ شود.',
],
},
{
slug: 'advertise',
title: 'آگهی در سایت',
description: 'فرصت‌های تبلیغاتی و همکاری رسانه‌ای با روس امروز',
body: [
'روس امروز امکان انتشار آگهی، رپورتاژ و کمپین‌های هدفمند برای مخاطبان علاقه‌مند به روسیه و اوراسیا را فراهم می‌کند.',
'برای دریافت تعرفه و پیشنهاد رسانه‌ای اختصاصی، با واحد بازرگانی سایت تماس بگیرید.',
],
},
{
slug: 'privacy',
title: 'حریم خصوصی',
description: 'سیاست حفظ حریم خصوصی کاربران روس امروز',
body: [
'روس امروز اطلاعات کاربران را تنها برای بهبود تجربه کاربری، ارسال خبرنامه و تحلیل عملکرد سایت استفاده می‌کند.',
'اطلاعات تماس کاربران بدون رضایت آن‌ها در اختیار اشخاص ثالث قرار نمی‌گیرد.',
],
},
];
export const readCms = async (): Promise<CmsData> => {
try {
const db = getDb();
seedIfEmpty(db);
const row = db.prepare('SELECT value FROM cms WHERE key = ?').get(CMS_KEY) as
| { value: string }
| undefined;
const data = row ? (JSON.parse(row.value) as CmsData) : (fallbackCms as CmsData);
return normalizeCms(data);
} catch {
return normalizeCms(fallbackCms as CmsData);
}
};
export const writeCms = async (data: CmsData) => {
const db = getDb();
const json = JSON.stringify(normalizeCms(data));
db.prepare(
`INSERT INTO cms (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
).run(CMS_KEY, json, Date.now());
};
const normalizeCms = (data: CmsData): CmsData => ({
...data,
settings: {
...data.settings,
breakingNews: {
...data.settings.breakingNews,
postSlugs: data.settings.breakingNews.postSlugs ?? [],
},
agents: {
...data.settings.agents,
tass: {
processedLinks: data.settings.agents?.tass?.processedLinks ?? [],
},
},
},
posts: data.posts.map((post) => ({
...post,
status: post.status ?? 'pending',
body: Array.isArray(post.body) ? post.body : [],
urgent: post.urgent ?? false,
sidebar: post.sidebar ?? false,
publishedAt: post.publishedAt ?? derivePublishedAt(post),
})),
});
export const getApprovedPosts = (data: CmsData) => data.posts.filter((post) => post.status === 'approved');
export const getCategoryPath = (category: string) => `/categories/${encodeURIComponent(category)}/`;
export const getStaticPageBySlug = (slug: string) => staticPages.find((page) => page.slug === slug);
export const getPrimaryNav = () => [
{ label: 'صفحه اصلی', href: '/' },
...staticPages
.filter((page) => ['about', 'contact'].includes(page.slug))
.map((page) => ({ label: page.navLabel ?? page.title, href: `/${page.slug}/` })),
];
export const getFooterPageLinks = () => staticPages.map((page) => ({
label: page.footerLabel ?? page.navLabel ?? page.title,
href: `/${page.slug}/`,
}));
export const getPostBySlug = (posts: Post[], slug: string) => posts.find((post) => post.slug === slug);
export const getPostsByCategory = (posts: Post[], category: string) => posts.filter((post) => post.category === category);
export const getPaginatedPosts = (page: number, perPage: number, source: Post[]) => {
const totalPages = Math.max(1, Math.ceil(source.length / perPage));
const currentPage = Math.min(Math.max(Math.floor(page), 1), totalPages);
const start = (currentPage - 1) * perPage;
return {
currentPage,
totalPages,
posts: source.slice(start, start + perPage),
};
};
export const searchPosts = (posts: Post[], query: string) => {
const normalizedQuery = query.trim().toLocaleLowerCase('fa-IR');
if (!normalizedQuery) {
return [];
}
return posts.filter((post) => {
const searchableText = [
post.title,
post.category,
post.excerpt,
...post.body,
].join(' ').toLocaleLowerCase('fa-IR');
return searchableText.includes(normalizedQuery);
});
};
export const slugify = (value: string) => value
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
export const parseImageBlock = (value: string) => {
const match = value.match(/^\[image:(.+?)(?:\|(.*))?\]$/);
if (!match) {
return null;
}
return {
src: match[1].trim(),
caption: match[2]?.trim() ?? '',
};
};

107
src/lib/date.ts Normal file
View File

@@ -0,0 +1,107 @@
// Helpers for Persian (Shamsi) dates and relative time labels used across the site.
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
/** Convert any latin digits in a string (or a number) to Persian digits. */
export const toPersianDigits = (value: string | number): string =>
String(value).replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit);
type DatedPost = { publishedAt?: string; slug: string; time: string };
/**
* Resolve the publish timestamp of a post.
*
* Posts created by the TASS agent embed `Date.now()` at the end of their slug,
* so we can recover a real timestamp even for older posts that only stored the
* placeholder string "هم‌اکنون".
*/
export const getPublishedAt = (post: DatedPost): Date | null => {
if (post.publishedAt) {
const date = new Date(post.publishedAt);
return Number.isNaN(date.getTime()) ? null : date;
}
const match = post.slug.match(/(\d{13})$/);
if (match) {
const date = new Date(Number(match[1]));
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
};
/** Derive an ISO timestamp for a post, or null when none can be inferred. */
export const derivePublishedAt = (post: DatedPost): string | undefined => {
const date = getPublishedAt(post);
return date ? date.toISOString() : undefined;
};
/** Exact Shamsi date + time, e.g. "۱۴ تیر ۱۴۰۵، ۱۴:۳۰". */
export const formatShamsiDate = (date: Date): string => {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return toPersianDigits(formatter.format(date));
};
/** Short Shamsi date without time, e.g. "۱۴ تیر ۱۴۰۵". */
export const formatShamsiDay = (date: Date): string => {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return toPersianDigits(formatter.format(date));
};
/**
* Relative Persian time label: "هم‌اکنون", "۲ دقیقه پیش", "۱۰ دقیقه پیش",
* "۳ ساعت پیش", "۵ روز پیش" — and falls back to an exact Shamsi date for
* anything older than a month.
*/
export const formatRelativeTime = (date: Date): string => {
const now = Date.now();
const diffSeconds = Math.max(0, Math.round((now - date.getTime()) / 1000));
if (diffSeconds < 60) {
return 'هم‌اکنون';
}
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) {
return `${toPersianDigits(diffMinutes)} دقیقه پیش`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${toPersianDigits(diffHours)} ساعت پیش`;
}
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) {
return `${toPersianDigits(diffDays)} روز پیش`;
}
return formatShamsiDay(date);
};
/** Relative label for a post, falling back to its stored `time` string. */
export const formatPostRelativeTime = (post: DatedPost): string => {
const date = getPublishedAt(post);
return date ? formatRelativeTime(date) : post.time;
};
/** Exact Shamsi label for a post, falling back to its stored `time` string. */
export const formatPostShamsiDate = (post: DatedPost): string => {
const date = getPublishedAt(post);
return date ? formatShamsiDate(date) : post.time;
};

75
src/lib/images.ts Normal file
View File

@@ -0,0 +1,75 @@
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import sharp from 'sharp';
const uploadDirectory = path.join(process.cwd(), 'public', 'uploads');
const thumbnailDirectory = path.join(uploadDirectory, 'thumbs');
export type StoredImage = {
image: string;
thumbnail: string;
};
export const ensureUploadDirectories = () => {
if (!existsSync(uploadDirectory)) {
mkdirSync(uploadDirectory, { recursive: true });
}
if (!existsSync(thumbnailDirectory)) {
mkdirSync(thumbnailDirectory, { recursive: true });
}
};
export const storeImageBuffer = async (sourceBuffer: Buffer): Promise<StoredImage> => {
ensureUploadDirectories();
const id = randomUUID();
const imageName = `${id}.webp`;
const thumbnailName = `${id}-thumb.webp`;
const imagePath = path.join(uploadDirectory, imageName);
const thumbnailPath = path.join(thumbnailDirectory, thumbnailName);
await sharp(sourceBuffer)
.rotate()
.resize({ width: 1400, withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(imagePath);
await sharp(sourceBuffer)
.rotate()
.resize(420, 260, { fit: 'cover' })
.webp({ quality: 76 })
.toFile(thumbnailPath);
return {
image: `/uploads/${imageName}`,
thumbnail: `/uploads/thumbs/${thumbnailName}`,
};
};
export const downloadAndStoreImage = async (url: string): Promise<StoredImage | null> => {
try {
const response = await fetch(url, {
headers: {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
referer: 'https://tass.ru/',
},
});
if (!response.ok) {
return null;
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) {
return null;
}
return storeImageBuffer(Buffer.from(await response.arrayBuffer()));
} catch {
return null;
}
};

66
src/lib/publisher.ts Normal file
View File

@@ -0,0 +1,66 @@
import { readCms, slugify, writeCms, type Post, type PostStatus } from './cms';
export type PublishPostInput = {
title: string;
category?: string;
time?: string;
readTime?: string;
excerpt?: string;
body?: string[] | string;
status?: PostStatus;
slug?: string;
image?: string;
thumbnail?: string;
urgent?: boolean;
sidebar?: boolean;
sourceUrl?: string;
publishedAt?: string;
};
export const publishPost = async (input: PublishPostInput) => {
const cms = await readCms();
const title = input.title.trim();
const slug = slugify(input.slug || title);
if (!title || !slug) {
throw new Error('title is required');
}
const body = Array.isArray(input.body)
? input.body
: String(input.body ?? '').split(/\n\s*\n/).map((item) => item.trim()).filter(Boolean);
const existingIndex = cms.posts.findIndex((item) => item.slug === slug);
const existing = existingIndex >= 0 ? cms.posts[existingIndex] : undefined;
const post: Post = {
slug,
title,
category: input.category || cms.categories[0] || 'عمومی',
time: input.time || 'هم‌اکنون',
readTime: input.readTime || '۳ دقیقه',
excerpt: input.excerpt || body[0] || '',
body,
status: input.status || 'pending',
image: input.image || undefined,
thumbnail: input.thumbnail || undefined,
urgent: input.urgent ?? false,
sidebar: input.sidebar ?? false,
sourceUrl: input.sourceUrl || undefined,
publishedAt: input.publishedAt || existing?.publishedAt || new Date().toISOString(),
};
if (existingIndex >= 0) {
cms.posts[existingIndex] = post;
} else {
cms.posts.unshift(post);
}
if (!cms.categories.includes(post.category)) {
cms.categories.push(post.category);
}
await writeCms(cms);
return post;
};

242
src/lib/puppeteerScraper.ts Normal file
View File

@@ -0,0 +1,242 @@
import puppeteer, { type Browser } from 'puppeteer-core';
import { existsSync } from 'node:fs';
let browserInstance: Browser | null = null;
const getChromePath = (): string => {
const platform = process.platform;
if (platform === 'darwin') {
const paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
];
for (const p of paths) {
if (existsSync(p)) return p;
}
}
if (platform === 'linux') {
const paths = [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
];
for (const p of paths) {
if (existsSync(p)) return p;
}
}
if (platform === 'win32') {
const paths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
];
for (const p of paths) {
if (existsSync(p)) return p;
}
}
return '';
};
export const getBrowser = async (): Promise<Browser> => {
if (browserInstance && browserInstance.connected) {
return browserInstance;
}
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH || getChromePath();
if (!chromePath) {
throw new Error('Chrome/Chromium not found. Install Chrome or set PUPPETEER_EXECUTABLE_PATH.');
}
browserInstance = await puppeteer.launch({
executablePath: chromePath,
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1920,1080',
],
});
return browserInstance;
};
export const closeBrowser = async () => {
if (browserInstance) {
await browserInstance.close();
browserInstance = null;
}
};
export type ScrapedArticle = {
title: string;
description: string;
imageUrl: string;
text: string;
allImages: string[];
};
export const scrapeArticle = async (url: string): Promise<ScrapedArticle> => {
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
);
// Block fonts, media, stylesheets to speed up page load
await page.setRequestInterception(true);
page.on('request', (req) => {
const type = req.resourceType();
if (['font', 'media'].includes(type)) {
req.abort();
} else {
req.continue();
}
});
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for content to settle
await new Promise((r) => setTimeout(r, 2000));
const data = await page.evaluate(() => {
const title =
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
document.querySelector('h1')?.textContent?.trim() ||
document.title;
const description =
document.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
document.querySelector('meta[name="description"]')?.getAttribute('content') ||
'';
const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute('content') || '';
// Collect article-specific images (avoid tracking pixels, logos, sidebar)
const allImages: string[] = [];
const imageBlacklist = ['logo', 'icon', 'avatar', 'counter', 'yadro', 'pixel', 'track', '1x1', 'badge'];
document.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || img.getAttribute('data-src') || '';
if (!src) return;
const lowerSrc = src.toLowerCase();
if (imageBlacklist.some(b => lowerSrc.includes(b))) return;
// Skip tiny images (likely icons/pixels)
const width = parseInt(img.getAttribute('width') || '0');
const height = parseInt(img.getAttribute('height') || '0');
if ((width > 0 && width < 50) || (height > 0 && height < 50)) return;
try {
allImages.push(new URL(src, window.location.origin).href);
} catch {}
});
// Try to find the main article image from content area
const contentContainer = document.querySelector('[class*="ContentPageContainer_content"]') ||
document.querySelector('article') ||
document.querySelector('[data-content]');
const articleImage = contentContainer?.querySelector('img')?.getAttribute('src') || '';
const imageUrl = ogImage || articleImage || '';
// Extract paragraphs from article content
const paragraphs: string[] = [];
// Build a set of footer/legal text to exclude
const footerTexts = new Set<string>();
document.querySelectorAll('[class*="Legal"], [class*="legal"], [class*="footer"], [class*="Footer"], [class*="Copyright"], [class*="copyright"], [class*="sidebar"], [class*="Sidebar"]').forEach(el => {
el.querySelectorAll('p, li').forEach(p => {
const t = p.textContent?.trim().replace(/\s+/g, ' ') || '';
if (t) footerTexts.add(t);
});
});
const selectors = [
'[class*="ContentPageContainer_content"] p',
'[class*="article"] p',
'[class*="Article"] p',
'[class*="news-text"] p',
'[class*="detail"] p',
'[class*="topic"] p',
'article p',
'[data-content] p',
'.text-content p',
'.article-body p',
];
for (const selector of selectors) {
document.querySelectorAll(selector).forEach((el) => {
const text = el.textContent?.trim().replace(/\s+/g, ' ') || '';
if (text.length > 20 && !paragraphs.includes(text) && !footerTexts.has(text)) {
paragraphs.push(text);
}
});
if (paragraphs.length > 0) break;
}
// Fallback: grab all paragraphs from main
if (paragraphs.length < 2) {
document.querySelectorAll('main p, .content p, .post-content p').forEach((el) => {
const text = el.textContent?.trim().replace(/\s+/g, ' ') || '';
if (text.length > 30 && !paragraphs.includes(text) && !footerTexts.has(text)) {
paragraphs.push(text);
}
});
}
// Last resort: any long paragraph
if (paragraphs.length < 2) {
document.querySelectorAll('p').forEach((el) => {
const text = el.textContent?.trim().replace(/\s+/g, ' ') || '';
if (text.length > 60 && !paragraphs.includes(text) && !footerTexts.has(text)) {
paragraphs.push(text);
}
});
}
return {
title: title?.trim() || '',
description: description?.trim() || '',
imageUrl,
text: paragraphs.slice(0, 14).join('\n\n'),
allImages,
};
});
// Resolve relative URLs
const resolvedImages = data.allImages.map((img) => {
try {
return new URL(img, url).href;
} catch {
return img;
}
});
return {
...data,
allImages: resolvedImages,
imageUrl: data.imageUrl
? (() => {
try {
return new URL(data.imageUrl, url).href;
} catch {
return data.imageUrl;
}
})()
: '',
};
} finally {
await page.close();
}
};

351
src/lib/tassAgent.ts Normal file
View File

@@ -0,0 +1,351 @@
import { XMLParser } from 'fast-xml-parser';
import OpenAI from 'openai';
import * as cheerio from 'cheerio';
import { downloadAndStoreImage } from './images';
import { publishPost } from './publisher';
import { readCms, slugify, writeCms } from './cms';
import { scrapeArticle, closeBrowser } from './puppeteerScraper';
const rssUrl = 'https://tass.ru/rss/v2.xml';
type RssItem = {
title?: string;
link?: string;
description?: string;
pubDate?: string;
enclosure?: {
'@_url'?: string;
'@_type'?: string;
};
'media:content'?: {
'@_url'?: string;
'@_type'?: string;
};
'media:thumbnail'?: {
'@_url'?: string;
};
};
type TranslatedPost = {
title: string;
excerpt: string;
body: string[];
category: string;
readTime: string;
};
// DONT USE ENV FOR THIS
const getOpenAIClient = () => new OpenAI({
apiKey: 'sk-C9kUNuxFPA6Jvpi2stzikxzZMJzPuYy5TIoJEJiVR89oeiD6',
baseURL: 'https://api.gapgpt.app/v1',
});
const getModel = () => 'gpt-5-nano';
const fetchText = async (url: string) => {
// Try plain fetch first
try {
const response = await fetch(url, {
headers: {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
referer: 'https://tass.ru/',
},
signal: AbortSignal.timeout(8000),
});
if (response.ok) {
return response.text();
}
} catch {}
// Fallback: use Puppeteer
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await new Promise((r) => setTimeout(r, 1000));
const content = await page.evaluate(() => {
const pre = document.querySelector('pre');
if (pre) return pre.textContent || '';
return document.body.innerText || document.body.textContent || '';
});
return content;
} finally {
await page.close();
}
};
export const getTassFeedLinks = async (limit = 50) => {
const xml = await fetchText(rssUrl);
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(xml);
const rawItems = parsed?.rss?.channel?.item ?? [];
const items = Array.isArray(rawItems) ? rawItems : [rawItems];
return (items.slice(0, limit) as RssItem[])
.map((item) => item.link)
.filter(Boolean) as string[];
};
const getItems = async (limit: number) => {
const xml = await fetchText(rssUrl);
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(xml);
const rawItems = parsed?.rss?.channel?.item ?? [];
const items = Array.isArray(rawItems) ? rawItems : [rawItems];
return items.slice(0, Math.max(limit * 10, 30)) as RssItem[];
};
const stripHtml = (value = '') => {
if (!value) return '';
const $ = cheerio.load(value);
return $.text().replace(/\s+/g, ' ').trim();
};
const getRssImage = (item: RssItem) => {
const candidates = [
item.enclosure?.['@_type']?.startsWith('image/') ? item.enclosure?.['@_url'] : '',
item['media:content']?.['@_type']?.startsWith('image/') ? item['media:content']?.['@_url'] : '',
item['media:thumbnail']?.['@_url'],
].filter(Boolean) as string[];
return candidates.find((candidate) => candidate.startsWith('http')) ?? '';
};
const sortImageItemsFirst = (items: RssItem[]) => [...items].sort((first, second) => {
const firstHasImage = getRssImage(first) ? 1 : 0;
const secondHasImage = getRssImage(second) ? 1 : 0;
return secondHasImage - firstHasImage;
});
const getFallbackArticle = (item: RssItem) => {
const description = stripHtml(item.description);
return {
title: item.title ?? '',
description,
imageUrl: getRssImage(item),
text: description,
usedFallback: true,
};
};
const toAbsoluteUrl = (value: string, baseUrl: string) => {
try {
return new URL(value, baseUrl).toString();
} catch {
return '';
}
};
const extractArticleCheerio = async (url: string) => {
const html = await fetchText(url);
const $ = cheerio.load(html);
const title =
$('meta[property="og:title"]').attr('content') ||
$('h1').first().text().replace(/\s+/g, ' ').trim();
const description = $('meta[property="og:description"]').attr('content') || '';
const imageCandidates = [
$('meta[property="og:image"]').attr('content'),
$('article img').first().attr('src'),
$('img').first().attr('src'),
].filter(Boolean) as string[];
const imageUrl =
imageCandidates
.map((c) => toAbsoluteUrl(c, url))
.find(Boolean) ?? '';
const paragraphs: string[] = [];
$('article p, [data-content] p, .text-content p, p').each((_, el) => {
const pText = $(el).text().replace(/\s+/g, ' ').trim();
if (pText.length > 80) {
paragraphs.push(pText);
}
});
return {
title,
description,
imageUrl,
text: paragraphs.slice(0, 14).join('\n\n'),
};
};
const extractArticle = async (url: string) => {
try {
const scraped = await scrapeArticle(url);
if (scraped.text || scraped.title) {
return {
title: scraped.title,
description: scraped.description,
imageUrl: scraped.imageUrl,
text: scraped.text,
usedFallback: false,
};
}
} catch {}
// Fallback to cheerio
const fallback = await extractArticleCheerio(url);
return {
...fallback,
usedFallback: true,
};
};
const translateArticle = async (article: { title: string; description: string; text: string }): Promise<TranslatedPost> => {
const client = getOpenAIClient();
if (!client.apiKey) {
throw new Error('GAPGPT_API_KEY or OPENAI_API_KEY is not configured');
}
const sourceText = article.text || article.description || article.title;
if (!sourceText) {
throw new Error('RSS item has no usable text');
}
const completion = await client.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'تو سردبیر فارسی یک سایت خبری هستی. متن روسی/انگلیسی خبر را به فارسی روان و خبری تبدیل کن. خروجی فقط JSON معتبر باشد.',
},
{
role: 'user',
content: JSON.stringify({
instruction: 'ترجمه و بازنویسی خبری فارسی بساز. دسته یکی از اقتصاد، سیاست، انرژی، فناوری، بین‌الملل باشد. body آرایه پاراگراف‌ها باشد.',
source: { ...article, text: sourceText },
schema: {
title: 'string',
excerpt: 'string',
category: 'string',
readTime: 'string',
body: ['string'],
},
}),
},
],
response_format: { type: 'json_object' },
temperature: 0.3,
});
const content = completion.choices[0]?.message?.content ?? '{}';
const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content;
const parsed = JSON.parse(jsonText) as Partial<TranslatedPost>;
return {
title: parsed.title?.trim() || article.title,
excerpt: parsed.excerpt?.trim() || article.description,
category: parsed.category?.trim() || 'بین‌الملل',
readTime: parsed.readTime?.trim() || '۳ دقیقه',
body: Array.isArray(parsed.body) ? parsed.body.map(String).filter(Boolean) : [article.text],
};
};
export const runTassAgent = async (limit = 3) => {
const cms = await readCms();
const processedLinks = new Set(cms.settings.agents?.tass?.processedLinks ?? []);
const items = sortImageItemsFirst(await getItems(limit));
const created = [];
const failed = [];
const skipped = [];
for (const item of items) {
if (created.length >= limit) {
break;
}
const link = item.link;
if (!link) {
continue;
}
if (processedLinks.has(link)) {
skipped.push(link);
continue;
}
try {
let article = getFallbackArticle(item);
let extractionWarning = '';
try {
const extractedArticle = await extractArticle(link);
article = {
...article,
...extractedArticle,
title: extractedArticle.title || article.title,
description: extractedArticle.description || article.description,
imageUrl: extractedArticle.imageUrl || article.imageUrl,
text: extractedArticle.text || article.text,
};
} catch (error) {
extractionWarning = error instanceof Error ? error.message : 'Article extraction failed';
}
const translated = await translateArticle({
title: article.title || item.title || '',
description: article.description || item.description || '',
text: article.text || item.description || '',
});
const storedImage = article.imageUrl ? await downloadAndStoreImage(article.imageUrl) : null;
const body = [...translated.body];
if (storedImage?.image) {
body.splice(1, 0, `[image:${storedImage.image}|${translated.title}]`);
}
const post = await publishPost({
title: translated.title,
slug: slugify(`${translated.title}-${Date.now()}`),
category: translated.category,
readTime: translated.readTime,
excerpt: translated.excerpt,
body,
status: 'pending',
image: storedImage?.image,
thumbnail: storedImage?.thumbnail,
sourceUrl: link,
});
processedLinks.add(link);
created.push({
slug: post.slug,
title: post.title,
source: link,
sourceImage: article.imageUrl || undefined,
image: storedImage?.image,
thumbnail: storedImage?.thumbnail,
warning: extractionWarning || undefined,
});
} catch (error) {
failed.push({ source: link, error: error instanceof Error ? error.message : 'Unknown error' });
}
}
const latestCms = await readCms();
latestCms.settings.agents = {
...latestCms.settings.agents,
tass: {
processedLinks: Array.from(processedLinks),
},
};
await writeCms(latestCms);
await closeBrowser();
return { created, failed, skipped };
};

24
src/pages/[page].astro Normal file
View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getStaticPageBySlug } from '../lib/cms';
const page = getStaticPageBySlug(Astro.params.page ?? '');
if (!page) {
return Astro.redirect('/');
}
---
<BaseLayout title={`${page.title} | روس امروز`} description={page.description}>
<main class="max-w-4xl mx-auto px-4 lg:px-8 py-8">
<article class="bg-white rounded-2xl shadow-sm p-5 sm:p-8">
<span class="text-brand text-sm font-bold">روس امروز</span>
<h1 class="text-2xl sm:text-4xl font-black leading-snug mt-2">{page.title}</h1>
<p class="text-gray-500 leading-relaxed mt-4">{page.description}</p>
<div class="space-y-5 text-gray-700 leading-8 text-base border-t border-gray-100 mt-6 pt-6">
{page.body.map((paragraph) => <p>{paragraph}</p>)}
</div>
</article>
</main>
</BaseLayout>

317
src/pages/admin/index.astro Normal file
View File

@@ -0,0 +1,317 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AdminPager from '../../components/admin/AdminPager.astro';
import { isAdminAuthenticated } from '../../lib/adminAuth';
import { readCms } from '../../lib/cms';
if (!isAdminAuthenticated(Astro)) {
return Astro.redirect('/admin/login/');
}
const PAGE_SIZE = 8;
const cms = await readCms();
const approvedPosts = cms.posts.filter((post) => post.status === 'approved');
const pendingPosts = cms.posts.filter((post) => post.status === 'pending');
const tassMemoryCount = cms.settings.agents?.tass?.processedLinks?.length ?? 0;
const saved = Astro.url.searchParams.has('saved');
const deleteError = Astro.url.searchParams.has('deleteError');
const editSlug = Astro.url.searchParams.get('edit');
const editingPost = cms.posts.find((post) => post.slug === editSlug);
const postForForm = editingPost ?? {
slug: '',
title: '',
category: cms.categories[0] ?? '',
time: 'هم‌اکنون',
readTime: '۳ دقیقه',
excerpt: '',
status: 'pending',
body: [],
image: '',
thumbnail: '',
urgent: false,
sidebar: false,
};
const selectedImage = postForForm.image || '';
const selectedThumbnail = postForForm.thumbnail || selectedImage;
const activeTab = Astro.url.searchParams.get('tab') === 'approved' ? 'approved' : 'pending';
const totalPagesFor = (items: { status: string }[]) => Math.max(1, Math.ceil(items.length / PAGE_SIZE));
const clampPage = (param: string, total: number) => {
const parsed = Number(Astro.url.searchParams.get(param) ?? '1');
return Math.min(Math.max(1, Number.isFinite(parsed) ? parsed : 1), total);
};
const pendingTotalPages = totalPagesFor(pendingPosts);
const approvedTotalPages = totalPagesFor(approvedPosts);
const pendingPage = clampPage('pendingPage', pendingTotalPages);
const approvedPage = clampPage('approvedPage', approvedTotalPages);
const pendingSlice = pendingPosts.slice((pendingPage - 1) * PAGE_SIZE, pendingPage * PAGE_SIZE);
const approvedSlice = approvedPosts.slice((approvedPage - 1) * PAGE_SIZE, approvedPage * PAGE_SIZE);
const tabClass = (id: string) => [
'px-4 h-9 rounded-lg border text-xs font-bold transition-colors',
id === activeTab ? 'bg-brand text-white border-brand' : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50',
].join(' ');
---
<BaseLayout title="پنل ادمین | روس امروز">
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<span class="text-brand text-sm font-bold">مدیریت محتوا</span>
<h1 class="text-2xl font-black mt-1">پنل سبک روس امروز</h1>
<p class="text-gray-500 text-sm mt-2">تایید نوشته‌های AI، مدیریت دسته‌ها، خبر فوری، آگهی و چیدمان صفحه اول</p>
</div>
<form action="/api/admin/logout" method="post">
<button class="bg-gray-900 text-white text-sm px-4 py-2 rounded-xl">خروج</button>
</form>
</div>
{saved && <p class="bg-green-50 text-green-700 text-sm rounded-xl px-4 py-3 mb-5">تغییرات ذخیره شد.</p>}
{deleteError && <p class="bg-red-50 text-brand text-sm rounded-xl px-4 py-3 mb-5">برای حذف باید دقیقاً confirm را وارد کنید.</p>}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-2xl shadow-sm p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="font-bold text-lg">مدیریت حذف اخبار</h2>
<p class="text-sm text-gray-500 mt-1">حذف همه اخبار نیاز به تایپ `confirm` دارد و قابل بازگشت نیست.</p>
</div>
<form action="/api/admin/save" method="post" data-confirm-form data-confirm-message="برای حذف همه اخبار عبارت confirm را تایپ کنید.">
<input type="hidden" name="action" value="delete-all-posts" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="confirm" value="" data-confirm-input />
<button class="bg-red-600 text-white text-sm px-4 py-2 rounded-xl">حذف همه اخبار</button>
</form>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<h2 class="font-bold text-lg">مدیریت نوشته‌ها</h2>
<nav data-active-tab={activeTab} class="flex gap-2" aria-label="تب نوشته‌ها">
<a href="/admin/?tab=pending" data-tab="pending" class={tabClass('pending')}>تایید نشده ({pendingPosts.length})</a>
<a href="/admin/?tab=approved" data-tab="approved" class={tabClass('approved')}>تایید شده ({approvedPosts.length})</a>
</nav>
</div>
<div data-panel="pending" class:list={['space-y-3', { hidden: activeTab !== 'pending' }]}>
{pendingSlice.length === 0 && <p class="text-sm text-gray-400">نوشته تایید نشده‌ای وجود ندارد.</p>}
{pendingSlice.map((post) => (
<article class="border border-gray-100 rounded-xl p-4">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<span class="text-xs text-brand font-bold">{post.category}</span>
<h3 class="font-bold mt-1">{post.title}</h3>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">{post.excerpt}</p>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href={`/admin/?tab=${activeTab}&edit=${post.slug}`} class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg">ویرایش</a>
<form action="/api/admin/save" method="post" data-confirm-form data-confirm-message={`برای حذف «${post.title}» عبارت confirm را تایپ کنید.`}>
<input type="hidden" name="action" value="delete-post" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="confirm" value="" data-confirm-input />
<button class="bg-red-50 text-brand text-xs px-3 py-2 rounded-lg">حذف</button>
</form>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="post-status" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="status" value="approved" />
<button class="bg-green-600 text-white text-xs px-3 py-2 rounded-lg">تایید</button>
</form>
</div>
</div>
</article>
))}
<AdminPager currentPage={pendingPage} totalPages={pendingTotalPages} tab="pending" pageParam="pendingPage" />
</div>
<div data-panel="approved" class:list={['space-y-3', { hidden: activeTab !== 'approved' }]}>
{approvedSlice.length === 0 && <p class="text-sm text-gray-400">نوشته تایید شده‌ای وجود ندارد.</p>}
{approvedSlice.map((post) => (
<article class="border border-gray-100 rounded-xl p-4">
<div class="flex flex-col gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-brand font-bold">{post.category}</span>
{post.urgent && <span class="bg-red-50 text-brand text-xs px-2 py-1 rounded-full">فوری</span>}
{post.sidebar && <span class="bg-blue-50 text-blue-700 text-xs px-2 py-1 rounded-full">پنل کناری</span>}
{(cms.settings.breakingNews.postSlugs ?? []).includes(post.slug) && <span class="bg-yellow-50 text-yellow-700 text-xs px-2 py-1 rounded-full">نوار بالایی</span>}
{cms.settings.homeLayout.featuredSlug === post.slug && <span class="bg-green-50 text-green-700 text-xs px-2 py-1 rounded-full">خبر اصلی</span>}
</div>
<h3 class="font-bold mt-2">{post.title}</h3>
</div>
<div class="flex flex-wrap gap-2">
<a href={`/admin/?tab=${activeTab}&edit=${post.slug}`} class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg">ویرایش</a>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="post-status" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="status" value="pending" />
<button class="bg-red-50 text-brand text-xs px-3 py-2 rounded-lg">برداشتن تایید</button>
</form>
<form action="/api/admin/save" method="post" data-confirm-form data-confirm-message={`برای حذف «${post.title}» عبارت confirm را تایپ کنید.`}>
<input type="hidden" name="action" value="delete-post" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="confirm" value="" data-confirm-input />
<button class="bg-red-50 text-brand text-xs px-3 py-2 rounded-lg">حذف</button>
</form>
</div>
<div class="flex flex-wrap gap-2">
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="toggle-post-flag" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="flag" value="urgent" />
<button class:list={['text-xs px-3 py-2 rounded-lg', post.urgent ? 'bg-brand text-white' : 'bg-red-50 text-brand']}>{post.urgent ? 'برداشتن فوری' : 'فوری کن'}</button>
</form>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="toggle-post-flag" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<input type="hidden" name="flag" value="sidebar" />
<button class:list={['text-xs px-3 py-2 rounded-lg', post.sidebar ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-700']}>{post.sidebar ? 'حذف از پنل کناری' : 'افزودن به پنل کناری'}</button>
</form>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="set-breaking-post" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<button class="bg-yellow-50 text-yellow-700 text-xs px-3 py-2 rounded-lg">متن فوری بالا</button>
</form>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="set-home-featured" />
<input type="hidden" name="tab" value={activeTab} />
<input type="hidden" name="slug" value={post.slug} />
<button class="bg-green-50 text-green-700 text-xs px-3 py-2 rounded-lg">خبر اصلی صفحه اول</button>
</form>
</div>
</div>
</article>
))}
<AdminPager currentPage={approvedPage} totalPages={approvedTotalPages} tab="approved" pageParam="approvedPage" />
</div>
</div>
</section>
<aside class="space-y-6">
<div class="bg-white rounded-2xl shadow-sm p-5 space-y-3">
<h2 class="font-bold text-lg">Agent خبرخوان TASS</h2>
<p class="text-xs text-gray-500 leading-6">RSS را می‌خواند، متن و تصویر خبر را استخراج می‌کند، با OpenAI-compatible API ترجمه می‌کند و پست را در وضعیت تایید نشده می‌سازد.</p>
<p class="text-xs text-gray-400">حافظه فعلی: {tassMemoryCount} لینک پردازش‌شده</p>
<button id="run-tass-agent" class="w-full bg-brand text-white rounded-xl py-3 font-bold">اجرای agent و ساخت پست</button>
<div class="grid grid-cols-2 gap-2">
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="clear-tass-memory" />
<input type="hidden" name="tab" value={activeTab} />
<button class="w-full bg-gray-100 text-gray-700 rounded-xl py-2 text-sm font-bold">خالی کردن حافظه</button>
</form>
<form action="/api/admin/save" method="post">
<input type="hidden" name="action" value="fill-tass-memory" />
<input type="hidden" name="tab" value={activeTab} />
<button class="w-full bg-gray-100 text-gray-700 rounded-xl py-2 text-sm font-bold">پر کردن از RSS فعلی</button>
</form>
</div>
<pre id="tass-agent-result" class="hidden bg-gray-900 text-white text-xs rounded-xl p-3 overflow-auto whitespace-pre-wrap"></pre>
</div>
<form action="/api/admin/save" method="post" class="bg-white rounded-2xl shadow-sm p-5 space-y-3">
<input type="hidden" name="action" value="save-post" />
<input type="hidden" name="originalSlug" value={editingPost?.slug ?? ''} />
<h2 class="font-bold text-lg">{editingPost ? 'ویرایش نوشته' : 'افزودن نوشته'}</h2>
<label class="block text-sm font-bold">عنوان<input name="title" value={postForForm.title} required class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">اسلاگ<input name="slug" value={postForForm.slug} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">دسته‌بندی<select name="category" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{cms.categories.map((category) => <option value={category} selected={category === postForForm.category}>{category}</option>)}</select></label>
<div class="grid grid-cols-2 gap-2">
<label class="block text-sm font-bold">زمان<input name="time" value={postForForm.time} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">مدت مطالعه<input name="readTime" value={postForForm.readTime} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
</div>
<label class="block text-sm font-bold">وضعیت<select name="status" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand"><option value="pending" selected={postForForm.status === 'pending'}>تایید نشده</option><option value="approved" selected={postForForm.status === 'approved'}>تایید شده</option></select></label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-sm font-bold bg-gray-50 rounded-xl px-3 py-2"><input type="checkbox" name="urgent" checked={postForForm.urgent} /> فوری</label>
<label class="flex items-center gap-2 text-sm font-bold bg-gray-50 rounded-xl px-3 py-2"><input type="checkbox" name="sidebar" checked={postForForm.sidebar} /> پنل کناری</label>
</div>
<div class="border border-gray-100 rounded-xl p-3 space-y-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-bold">تصویر شاخص</span>
<button type="button" data-insert-image={selectedImage} disabled={!selectedImage} class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg disabled:opacity-40">درج داخل متن</button>
</div>
<div id="new-image-preview" class="w-full h-36 rounded-xl bg-gray-100 flex items-center justify-center overflow-hidden">
{selectedThumbnail ? (
<img src={selectedThumbnail} alt="" class="w-full h-full object-cover" />
) : (
<span class="text-gray-400 text-sm">تصویری انتخاب نشده</span>
)}
</div>
<p id="upload-status" class="text-xs rounded-lg px-2.5 py-1 text-gray-400">تصویری انتخاب نشده</p>
<div class="flex flex-col sm:flex-row gap-2">
<input id="new-image-file" type="file" accept="image/*" class="w-full bg-gray-100 rounded-xl px-3 py-2 text-sm" />
<button id="new-upload-btn" type="button" class="bg-gray-900 text-white rounded-xl px-4 py-2 text-sm font-bold sm:flex-shrink-0">آپلود و ساخت thumbnail</button>
</div>
<input id="new-image-field" name="image" value={selectedImage} placeholder="/uploads/image.webp" class="w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand text-sm" />
<input id="new-thumb-field" name="thumbnail" value={selectedThumbnail} placeholder="/uploads/thumbs/image-thumb.webp" class="w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand text-sm" />
</div>
<label class="block text-sm font-bold">خلاصه<textarea name="excerpt" rows="3" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{postForForm.excerpt}</textarea></label>
<label class="block text-sm font-bold">
متن
<div class="flex flex-wrap gap-2 mt-2 mb-2">
<button type="button" data-editor-command="heading" class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg">تیتر</button>
<button type="button" data-editor-command="quote" class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg">نقل‌قول</button>
<button type="button" data-editor-command="separator" class="bg-gray-100 text-gray-700 text-xs px-3 py-2 rounded-lg">جداکننده</button>
<button type="button" data-insert-image={selectedImage} disabled={!selectedImage} class="bg-red-50 text-brand text-xs px-3 py-2 rounded-lg disabled:opacity-40">درج تصویر آپلودشده</button>
</div>
<textarea id="post-body-editor" name="body" rows="12" class="w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand leading-7 font-mono text-sm">{postForForm.body.join('\n\n')}</textarea>
<span class="block text-xs text-gray-400 mt-2">برای تصویر داخل متن از `[image:/uploads/example.webp|توضیح تصویر]` استفاده می‌شود.</span>
</label>
<button class="w-full bg-brand text-white rounded-xl py-3 font-bold">ذخیره نوشته</button>
</form>
<form action="/api/admin/save" method="post" class="bg-white rounded-2xl shadow-sm p-5 space-y-3">
<input type="hidden" name="action" value="categories" />
<input type="hidden" name="tab" value={activeTab} />
<h2 class="font-bold text-lg">دسته‌بندی‌ها</h2>
<textarea name="categories" rows="5" class="w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{cms.categories.join('\n')}</textarea>
<button class="w-full bg-gray-900 text-white rounded-xl py-3 font-bold">ذخیره دسته‌ها</button>
</form>
<form action="/api/admin/save" method="post" class="bg-white rounded-2xl shadow-sm p-5 space-y-3">
<input type="hidden" name="action" value="settings" />
<input type="hidden" name="tab" value={activeTab} />
<h2 class="font-bold text-lg">صفحه اول و آگهی</h2>
<label class="block text-sm font-bold">برچسب فوری<input name="breakingLabel" value={cms.settings.breakingNews.label} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">لینک فوری<input name="breakingHref" value={cms.settings.breakingNews.href} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">متن‌های فوری<textarea name="breakingItems" rows="4" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{cms.settings.breakingNews.items.join('\n')}</textarea></label>
<label class="block text-sm font-bold">اسلاگ پست‌های نوار فوری<textarea name="breakingPostSlugs" rows="3" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{(cms.settings.breakingNews.postSlugs ?? []).join('\n')}</textarea></label>
<label class="block text-sm font-bold">موضوعات داغ<textarea name="hotTopics" rows="4" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{cms.settings.hotTopics.join('\n')}</textarea></label>
<label class="block text-sm font-bold">خبر اصلی<select name="featuredSlug" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{approvedPosts.map((post) => <option value={post.slug} selected={post.slug === cms.settings.homeLayout.featuredSlug}>{post.title}</option>)}</select></label>
<label class="block text-sm font-bold">دو خبر کناری<textarea name="sideFeaturedSlugs" rows="3" class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand">{cms.settings.homeLayout.sideFeaturedSlugs.join('\n')}</textarea></label>
<label class="block text-sm font-bold">تعداد آخرین اخبار<input name="latestCount" type="number" min="1" value={cms.settings.homeLayout.latestCount} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="flex items-center gap-2 text-sm font-bold"><input type="checkbox" name="showAd" checked={cms.settings.homeLayout.showAd} /> نمایش جایگاه آگهی</label>
<label class="flex items-center gap-2 text-sm font-bold"><input type="checkbox" name="adEnabled" checked={cms.settings.ad.enabled} /> آگهی فعال</label>
<label class="block text-sm font-bold">عنوان آگهی<input name="adLabel" value={cms.settings.ad.label} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">لینک آگهی<input name="adHref" value={cms.settings.ad.href} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<label class="block text-sm font-bold">متن آگهی<input name="adText" value={cms.settings.ad.text} class="mt-2 w-full bg-gray-100 rounded-xl px-3 py-2 outline-none focus:ring-1 focus:ring-brand" /></label>
<button class="w-full bg-brand text-white rounded-xl py-3 font-bold">ذخیره تنظیمات</button>
</form>
<div class="bg-white rounded-2xl shadow-sm p-5 space-y-3">
<h2 class="font-bold text-lg">API انتشار برای n8n</h2>
<p class="text-xs text-gray-500 leading-6">POST به `/api/posts/publish` با هدر `Authorization: Bearer ADMIN_API_TOKEN`.</p>
<code class="block bg-gray-100 text-xs rounded-xl p-3 leading-6 overflow-auto">{JSON.stringify({ title: 'عنوان', category: 'بین‌الملل', body: ['پاراگراف اول'], imageUrl: 'https://...' })}</code>
</div>
</aside>
</div>
</main>
</BaseLayout>
<script>
import { adminSdk } from '../../scripts/admin-sdk';
adminSdk.initTabs();
adminSdk.initUpload();
adminSdk.initEditor();
adminSdk.initConfirm();
adminSdk.initTass();
</script>

View File

@@ -0,0 +1,39 @@
---
import '../../styles/global.css';
import { isAdminAuthenticated } from '../../lib/adminAuth';
if (isAdminAuthenticated(Astro)) {
return Astro.redirect('/admin/');
}
const error = Astro.url.searchParams.has('error');
---
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ورود ادمین | روس امروز</title>
<link rel="stylesheet" href="https://rc0.ir/vazirmatn/Vazirmatn-font-face.css" />
</head>
<body class="bg-gray-100 font-vazir text-gray-900 min-h-screen flex items-center justify-center p-4">
<form action="/api/admin/login" method="post" class="bg-white rounded-2xl shadow-sm p-6 w-full max-w-sm space-y-4">
<div>
<span class="text-brand text-sm font-bold">روس امروز</span>
<h1 class="text-2xl font-black mt-1">ورود ادمین</h1>
</div>
{error && <p class="bg-red-50 text-brand text-sm rounded-xl px-3 py-2">نام کاربری یا رمز عبور اشتباه است.</p>}
<label class="block text-sm font-bold">
نام کاربری
<input name="username" value="admin" class="mt-2 w-full bg-gray-100 rounded-xl px-4 py-3 outline-none focus:ring-1 focus:ring-brand" />
</label>
<label class="block text-sm font-bold">
رمز عبور
<input name="password" type="password" placeholder="admin123" class="mt-2 w-full bg-gray-100 rounded-xl px-4 py-3 outline-none focus:ring-1 focus:ring-brand" />
</label>
<button class="w-full bg-brand text-white rounded-xl py-3 font-bold hover:bg-red-700 transition-colors">ورود</button>
<p class="text-xs text-gray-400 leading-6">پیش‌فرض: `admin` / `admin123`؛ برای production از `ADMIN_USER` و `ADMIN_PASSWORD` استفاده کنید.</p>
</form>
</body>
</html>

View File

@@ -0,0 +1,16 @@
import type { APIRoute } from 'astro';
import { isValidLogin, setAdminSession } from '../../../lib/adminAuth';
export const POST: APIRoute = async (context) => {
const formData = await context.request.formData();
const username = String(formData.get('username') ?? '');
const password = String(formData.get('password') ?? '');
if (!isValidLogin(username, password)) {
return context.redirect('/admin/login/?error=1', 302);
}
setAdminSession(context);
return context.redirect('/admin/', 302);
};

View File

@@ -0,0 +1,8 @@
import type { APIRoute } from 'astro';
import { clearAdminSession } from '../../../lib/adminAuth';
export const POST: APIRoute = async (context) => {
clearAdminSession(context);
return context.redirect('/admin/login/', 302);
};

172
src/pages/api/admin/save.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { APIRoute } from 'astro';
import { requireAdmin } from '../../../lib/adminAuth';
import { readCms, writeCms, type PostStatus } from '../../../lib/cms';
import { publishPost } from '../../../lib/publisher';
import { getTassFeedLinks } from '../../../lib/tassAgent';
const toList = (value: FormDataEntryValue | null) => String(value ?? '')
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
const toParagraphs = (value: FormDataEntryValue | null) => String(value ?? '')
.split(/\n\s*\n/)
.map((item) => item.trim())
.filter(Boolean);
export const POST: APIRoute = async (context) => {
const unauthorized = requireAdmin(context);
if (unauthorized) {
return unauthorized;
}
const formData = await context.request.formData();
const action = String(formData.get('action') ?? '');
const cms = await readCms();
const tab = String(formData.get('tab') ?? 'pending');
if (action === 'post-status') {
const slug = String(formData.get('slug') ?? '');
const status = String(formData.get('status') ?? 'pending') as PostStatus;
cms.posts = cms.posts.map((post) => (
post.slug === slug ? { ...post, status } : post
));
}
if (action === 'toggle-post-flag') {
const slug = String(formData.get('slug') ?? '');
const flag = String(formData.get('flag') ?? '');
cms.posts = cms.posts.map((post) => {
if (post.slug !== slug) {
return post;
}
if (flag === 'urgent') {
return { ...post, urgent: !post.urgent };
}
if (flag === 'sidebar') {
return { ...post, sidebar: !post.sidebar };
}
return post;
});
}
if (action === 'set-breaking-post') {
const slug = String(formData.get('slug') ?? '');
const post = cms.posts.find((item) => item.slug === slug);
if (post) {
cms.settings.breakingNews = {
...cms.settings.breakingNews,
href: `/posts/${post.slug}/`,
items: [post.title],
postSlugs: [post.slug],
};
}
}
if (action === 'set-home-featured') {
const slug = String(formData.get('slug') ?? '');
cms.settings.homeLayout.featuredSlug = slug;
}
if (action === 'clear-tass-memory') {
cms.settings.agents = {
...cms.settings.agents,
tass: { processedLinks: [] },
};
}
if (action === 'fill-tass-memory') {
const links = cms.posts
.map((post) => post.sourceUrl)
.filter(Boolean) as string[];
const feedLinks = await getTassFeedLinks(50);
cms.settings.agents = {
...cms.settings.agents,
tass: { processedLinks: Array.from(new Set([...links, ...feedLinks])) },
};
}
if (action === 'delete-post') {
const slug = String(formData.get('slug') ?? '');
const confirm = String(formData.get('confirm') ?? '');
if (confirm !== 'confirm') {
return context.redirect(`/admin/?deleteError=1&tab=${encodeURIComponent(tab)}`, 302);
}
cms.posts = cms.posts.filter((post) => post.slug !== slug);
}
if (action === 'delete-all-posts') {
const confirm = String(formData.get('confirm') ?? '');
if (confirm !== 'confirm') {
return context.redirect(`/admin/?deleteError=1&tab=${encodeURIComponent(tab)}`, 302);
}
cms.posts = [];
}
if (action === 'save-post') {
const originalSlug = String(formData.get('originalSlug') ?? '');
const title = String(formData.get('title') ?? '').trim();
const post = await publishPost({
slug: String(formData.get('slug') ?? '') || title,
title,
category: String(formData.get('category') ?? cms.categories[0] ?? 'عمومی'),
time: String(formData.get('time') ?? '').trim() || 'هم‌اکنون',
readTime: String(formData.get('readTime') ?? '').trim() || '۳ دقیقه',
excerpt: String(formData.get('excerpt') ?? '').trim(),
status: String(formData.get('status') ?? 'pending') as PostStatus,
body: toParagraphs(formData.get('body')),
image: String(formData.get('image') ?? '').trim() || undefined,
thumbnail: String(formData.get('thumbnail') ?? '').trim() || undefined,
urgent: formData.get('urgent') === 'on',
sidebar: formData.get('sidebar') === 'on',
});
if (originalSlug && originalSlug !== post.slug) {
const updatedCms = await readCms();
updatedCms.posts = updatedCms.posts.filter((post) => post.slug !== originalSlug);
await writeCms(updatedCms);
}
return context.redirect(`/admin/?saved=1&tab=${post.status === 'approved' ? 'approved' : 'pending'}`, 302);
}
if (action === 'categories') {
cms.categories = toList(formData.get('categories'));
}
if (action === 'settings') {
cms.settings.breakingNews = {
label: String(formData.get('breakingLabel') ?? 'فوری').trim(),
href: String(formData.get('breakingHref') ?? '/').trim(),
items: toList(formData.get('breakingItems')),
postSlugs: toList(formData.get('breakingPostSlugs')),
};
cms.settings.hotTopics = toList(formData.get('hotTopics'));
cms.settings.homeLayout = {
featuredSlug: String(formData.get('featuredSlug') ?? '').trim(),
sideFeaturedSlugs: toList(formData.get('sideFeaturedSlugs')).slice(0, 2),
latestCount: Math.max(1, Number(formData.get('latestCount') ?? 4)),
showAd: formData.get('showAd') === 'on',
};
cms.settings.ad = {
enabled: formData.get('adEnabled') === 'on',
label: String(formData.get('adLabel') ?? 'آگهی').trim(),
href: String(formData.get('adHref') ?? '/advertise/').trim(),
text: String(formData.get('adText') ?? '').trim(),
};
}
await writeCms(cms);
return context.redirect(`/admin/?saved=1&tab=${encodeURIComponent(tab)}`, 302);
};

View File

@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { requireAdmin } from '../../../lib/adminAuth';
import { storeImageBuffer } from '../../../lib/images';
export const POST: APIRoute = async (context) => {
const unauthorized = requireAdmin(context);
if (unauthorized) {
return unauthorized;
}
const formData = await context.request.formData();
const file = formData.get('image');
if (!(file instanceof File) || file.size === 0) {
return context.json({ error: 'فایل تصویری ارسال نشده است.' }, 400);
}
try {
const storedImage = await storeImageBuffer(Buffer.from(await file.arrayBuffer()));
return context.json(storedImage, 200);
} catch {
return context.json({ error: 'پردازش تصویر ناموفق بود.' }, 500);
}
};

View File

@@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
import { requireApiToken } from '../../../lib/apiAuth';
import { isAdminAuthenticated } from '../../../lib/adminAuth';
import { runTassAgent } from '../../../lib/tassAgent';
export const POST: APIRoute = async (context) => {
if (!isAdminAuthenticated(context)) {
const unauthorized = requireApiToken(context);
if (unauthorized) {
return unauthorized;
}
}
try {
const contentType = context.request.headers.get('content-type') ?? '';
const payload = contentType.includes('application/json') ? await context.request.json() : {};
const limit = Math.min(Math.max(Number(payload.limit ?? 3), 1), 10);
const result = await runTassAgent(limit);
return new Response(JSON.stringify({ ok: true, ...result }), {
headers: { 'content-type': 'application/json; charset=utf-8' },
});
} catch (error) {
return new Response(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : 'Agent failed' }), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
};

View File

@@ -0,0 +1,46 @@
import type { APIRoute } from 'astro';
import { requireApiToken } from '../../../lib/apiAuth';
import { publishPost } from '../../../lib/publisher';
import { downloadAndStoreImage } from '../../../lib/images';
export const POST: APIRoute = async (context) => {
const unauthorized = requireApiToken(context);
if (unauthorized) {
return unauthorized;
}
try {
const payload = await context.request.json();
let image = payload.image;
let thumbnail = payload.thumbnail;
if (payload.imageUrl && !image) {
const storedImage = await downloadAndStoreImage(String(payload.imageUrl));
image = storedImage?.image;
thumbnail = storedImage?.thumbnail;
}
const post = await publishPost({
title: String(payload.title ?? ''),
slug: payload.slug ? String(payload.slug) : undefined,
category: payload.category ? String(payload.category) : undefined,
time: payload.time ? String(payload.time) : undefined,
readTime: payload.readTime ? String(payload.readTime) : undefined,
excerpt: payload.excerpt ? String(payload.excerpt) : undefined,
body: Array.isArray(payload.body) ? payload.body.map(String) : String(payload.body ?? ''),
status: payload.status === 'approved' ? 'approved' : 'pending',
image,
thumbnail,
});
return new Response(JSON.stringify({ ok: true, post }), {
headers: { 'content-type': 'application/json; charset=utf-8' },
});
} catch (error) {
return new Response(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : 'Invalid request' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
};

View File

@@ -0,0 +1,28 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { getApprovedPosts, getPostsByCategory, readCms } from '../../lib/cms';
const cms = await readCms();
const category = decodeURIComponent(Astro.params.category ?? '');
if (!cms.categories.includes(category)) {
return Astro.redirect('/posts/');
}
const categoryPosts = getPostsByCategory(getApprovedPosts(cms), category);
---
<BaseLayout title={`${category} | روس امروز`} description={`آخرین اخبار و تحلیل‌های ${category} در روس امروز`}>
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-8">
<div class="mb-6">
<a href="/posts/" class="text-brand text-sm font-bold">آرشیو اخبار</a>
<h1 class="text-2xl font-black mt-1">اخبار {category}</h1>
<p class="text-gray-500 text-sm mt-2">مجموعه خبرها و تحلیل‌های منتشر شده در بخش {category}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{categoryPosts.map((post) => <PostCard post={post} />)}
</div>
</main>
</BaseLayout>

140
src/pages/index.astro Normal file
View File

@@ -0,0 +1,140 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Pagination from '../components/Pagination.astro';
import PostCard from '../components/PostCard.astro';
import { getApprovedPosts, getCategoryPath, getPaginatedPosts, getPostBySlug, readCms } from '../lib/cms';
import { formatPostRelativeTime } from '../lib/date';
const cms = await readCms();
const posts = getApprovedPosts(cms);
const { homeLayout, hotTopics, ad } = cms.settings;
const featuredPost = getPostBySlug(posts, homeLayout.featuredSlug) ?? posts[0];
const pageParam = Number(Astro.url.searchParams.get('page') ?? '1');
const requestedPage = Number.isFinite(pageParam) ? pageParam : 1;
const sideFeaturedPosts = [
...posts.filter((post) => post.sidebar),
...homeLayout.sideFeaturedSlugs
.map((slug) => getPostBySlug(posts, slug))
.filter(Boolean),
]
.filter((post, index, list) => list.findIndex((item) => item.slug === post.slug) === index)
.slice(0, 2);
const latestSource = posts.filter((post) => post.slug !== featuredPost?.slug);
const { currentPage, totalPages, posts: latestPosts } = getPaginatedPosts(requestedPage, homeLayout.latestCount, latestSource);
const mostViewedPosts = posts.slice(1, 6);
---
<BaseLayout>
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-6">
{featuredPost && (
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start mb-5 lg:mb-6">
<a href={`/posts/${featuredPost.slug}/`} class="lg:col-span-2 bg-white rounded-2xl overflow-hidden shadow-sm group">
<div class="img-placeholder h-64 sm:h-80 lg:h-[430px] w-full relative overflow-hidden">
{featuredPost.image ? (
<img src={featuredPost.image} alt={featuredPost.title} class="absolute inset-0 w-full h-full object-cover" />
) : (
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
<span class="material-icons text-5xl opacity-30">image</span>
</div>
)}
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
<div class="absolute bottom-0 p-4 sm:p-6">
<span class="bg-brand text-white text-xs px-2.5 py-1 rounded-full font-bold">ویژه</span>
<h1 class="text-white font-bold text-xl sm:text-2xl mt-2 leading-snug line-clamp-2 group-hover:text-red-200 transition-colors">
{featuredPost.title}
</h1>
<p class="text-white/75 text-sm mt-1 hidden sm:block line-clamp-2">{featuredPost.excerpt}</p>
<div class="flex items-center gap-3 mt-3 text-white/60 text-xs">
<span class="material-icons text-sm">schedule</span>
<span>{formatPostRelativeTime(featuredPost)}</span>
<span>·</span>
<span>{featuredPost.category}</span>
<span>·</span>
<span>{featuredPost.readTime} مطالعه</span>
</div>
</div>
</div>
</a>
<div class="flex flex-col gap-4 lg:col-span-1">
<div class="grid grid-cols-2 lg:grid-cols-1 gap-4">
{
sideFeaturedPosts.map((post) => (
<a href={`/posts/${post.slug}/`} class="bg-white rounded-2xl overflow-hidden shadow-sm group">
<div class="img-placeholder h-28 lg:h-36 w-full relative overflow-hidden">
{post.thumbnail || post.image ? (
<img src={post.thumbnail || post.image} alt={post.title} class="w-full h-full object-cover" loading="lazy" />
) : (
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
<span class="material-icons text-3xl opacity-30">image</span>
</div>
)}
</div>
<div class="p-3">
<span class="text-brand text-xs font-bold">{post.category}</span>
<h2 class="font-semibold text-sm mt-1 leading-snug line-clamp-2 group-hover:text-brand transition-colors">{post.title}</h2>
<p class="text-xs text-gray-400 mt-1">{formatPostRelativeTime(post)}</p>
</div>
</a>
))
}
</div>
</div>
</section>
)}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-lg">آخرین اخبار</h2>
<a href="/posts/" class="text-brand text-sm hover:underline">همه اخبار</a>
</div>
<div class="space-y-4">
{latestPosts.map((post) => <PostCard post={post} />)}
</div>
<Pagination currentPage={currentPage} totalPages={totalPages} basePath="/" />
</div>
<aside class="lg:col-span-1 space-y-6">
<div class="bg-white rounded-2xl p-4 shadow-sm">
<h3 class="font-bold text-base mb-4 flex items-center gap-2">
<span class="material-icons text-brand text-xl">trending_up</span>
پربازدیدترین
</h3>
<ol class="space-y-3">
{
mostViewedPosts.map((item, index) => (
<li class="flex items-start gap-3 group">
<span class="text-2xl font-black text-gray-100 leading-none flex-shrink-0 w-6 text-center">{index + 1}</span>
<div>
<a href={`/posts/${item.slug}/`} class="text-sm font-medium leading-snug group-hover:text-brand transition-colors line-clamp-2">{item.title}</a>
<a href={getCategoryPath(item.category)} class="text-xs text-brand">{item.category}</a>
</div>
</li>
))
}
</ol>
</div>
{homeLayout.showAd && ad.enabled && (
<a href={ad.href} class="img-placeholder rounded-2xl h-48 flex items-center justify-center text-gray-400">
<div class="text-center">
<span class="material-icons text-4xl opacity-30">campaign</span>
<p class="text-xs mt-1 opacity-40">{ad.label}</p>
<p class="text-sm mt-2 text-gray-500">{ad.text}</p>
</div>
</a>
)}
<div class="bg-white rounded-2xl p-4 shadow-sm">
<h3 class="font-bold text-base mb-3">موضوعات داغ</h3>
<div class="flex flex-wrap gap-2">
{hotTopics.map((tag) => <a href={`/search/?q=${encodeURIComponent(tag)}`} class="bg-gray-100 hover:bg-red-50 hover:text-brand text-gray-600 text-xs px-3 py-1.5 rounded-full transition-colors">#{tag}</a>)}
</div>
</div>
</aside>
</div>
</main>
</BaseLayout>

View File

@@ -0,0 +1,87 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { getApprovedPosts, getCategoryPath, getPostBySlug, parseImageBlock, readCms } from '../../lib/cms';
import { formatPostShamsiDate } from '../../lib/date';
const cms = await readCms();
const posts = getApprovedPosts(cms);
const post = getPostBySlug(posts, Astro.params.slug ?? '');
if (!post) {
return Astro.redirect('/posts/');
}
const dateLabel = formatPostShamsiDate(post);
const relatedPosts = posts.filter((item) => item.slug !== post.slug).slice(0, 3);
---
<BaseLayout title={`${post.title} | روس امروز`} description={post.excerpt}>
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-8">
<article class="bg-white rounded-2xl shadow-sm overflow-hidden">
<div class="img-placeholder h-64 sm:h-80 lg:h-96 relative overflow-hidden">
{post.image ? (
<img src={post.image} alt={post.title} class="w-full h-full object-cover" />
) : (
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
<span class="material-icons text-6xl opacity-30">image</span>
</div>
)}
</div>
<div class="max-w-3xl mx-auto p-5 sm:p-8">
<a href={getCategoryPath(post.category)} class="text-brand text-sm font-bold">{post.category}</a>
<h1 class="text-2xl sm:text-4xl font-black leading-snug mt-3">{post.title}</h1>
<p class="text-gray-500 leading-relaxed mt-4">{post.excerpt}</p>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 border-y border-gray-100 py-4 my-6">
<span class="material-icons text-base">schedule</span>
<span>{dateLabel}</span>
<span>·</span>
<span>{post.readTime} مطالعه</span>
<span>·</span>
<span>تحریریه روس امروز</span>
</div>
<div class="space-y-5 text-gray-700 leading-8 text-base">
{post.body.map((paragraph) => {
const imageBlock = parseImageBlock(paragraph);
if (imageBlock) {
return (
<figure class="my-7">
<img src={imageBlock.src} alt={imageBlock.caption || post.title} class="w-full rounded-2xl object-cover" loading="lazy" />
{imageBlock.caption && <figcaption class="text-xs text-gray-400 mt-2 text-center">{imageBlock.caption}</figcaption>}
</figure>
);
}
if (paragraph === '---') {
return <hr class="border-gray-100 my-7" />;
}
if (paragraph.startsWith('## ')) {
return <h2 class="text-xl font-black text-gray-900 mt-8">{paragraph.replace(/^##\s+/, '')}</h2>;
}
if (paragraph.startsWith('> ')) {
return <blockquote class="border-r-4 border-brand bg-red-50/60 rounded-l-xl px-4 py-3 text-gray-700">{paragraph.replace(/^>\s+/, '')}</blockquote>;
}
return <p>{paragraph}</p>;
})}
</div>
</div>
</article>
<section class="mt-10">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-lg">نوشته‌های مرتبط</h2>
<a href="/posts/" class="text-brand text-sm hover:underline">همه اخبار</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
{relatedPosts.map((relatedPost) => <PostCard post={relatedPost} />)}
</div>
</section>
</main>
</BaseLayout>

View File

@@ -0,0 +1,27 @@
---
import PostCard from '../../components/PostCard.astro';
import Pagination from '../../components/Pagination.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getApprovedPosts, getPaginatedPosts, readCms } from '../../lib/cms';
const cms = await readCms();
const pageParam = Number(Astro.url.searchParams.get('page') ?? '1');
const requestedPage = Number.isFinite(pageParam) ? pageParam : 1;
const { currentPage, totalPages, posts } = getPaginatedPosts(requestedPage, 4, getApprovedPosts(cms));
---
<BaseLayout title="همه اخبار | روس امروز">
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-8">
<div class="mb-6">
<span class="text-brand text-sm font-bold">آرشیو</span>
<h1 class="text-2xl font-black mt-1">همه اخبار</h1>
<p class="text-gray-500 text-sm mt-2">لیست کامل نوشته‌ها و خبرهای منتشر شده در روس امروز</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{posts.map((post) => <PostCard post={post} />)}
</div>
<Pagination currentPage={currentPage} totalPages={totalPages} />
</main>
</BaseLayout>

View File

@@ -0,0 +1,62 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { getApprovedPosts, readCms, searchPosts } from '../../lib/cms';
const cms = await readCms();
const query = Astro.url.searchParams.get('q')?.trim() ?? '';
const results = searchPosts(getApprovedPosts(cms), query);
const title = query ? `جستجو: ${query} | روس امروز` : 'جستجو | روس امروز';
---
<BaseLayout title={title} description="جستجو در خبرها و تحلیل‌های روس امروز">
<main class="max-w-7xl mx-auto px-4 lg:px-8 py-8">
<div class="bg-white rounded-2xl shadow-sm p-5 sm:p-6 mb-6">
<span class="text-brand text-sm font-bold">جستجو</span>
<h1 class="text-2xl font-black mt-1">جستجو در روس امروز</h1>
<form action="/search/" method="get" class="mt-5 flex gap-2">
<input
type="search"
name="q"
value={query}
placeholder="عبارت مورد نظر را وارد کنید..."
class="flex-1 bg-gray-100 text-gray-700 text-sm rounded-xl px-4 py-3 outline-none focus:ring-1 focus:ring-brand focus:bg-white"
/>
<button type="submit" class="bg-brand text-white text-sm font-bold px-5 py-3 rounded-xl hover:bg-red-700 transition-colors">
جستجو
</button>
</form>
</div>
{
query ? (
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-lg">نتایج برای «{query}»</h2>
<span class="text-sm text-gray-400">{results.length} نتیجه</span>
</div>
{results.length > 0 ? (
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{results.map((post) => <PostCard post={post} />)}
</div>
) : (
<div class="bg-white rounded-2xl shadow-sm p-8 text-center text-gray-500">
نتیجه‌ای برای این عبارت پیدا نشد.
</div>
)}
</section>
) : (
<section class="bg-white rounded-2xl shadow-sm p-5">
<h2 class="font-bold text-base mb-3">موضوعات پیشنهادی</h2>
<div class="flex flex-wrap gap-2">
{cms.settings.hotTopics.map((tag) => (
<a href={`/search/?q=${encodeURIComponent(tag)}`} class="bg-gray-100 hover:bg-red-50 hover:text-brand text-gray-600 text-xs px-3 py-1.5 rounded-full transition-colors">
#{tag}
</a>
))}
</div>
</section>
)
}
</main>
</BaseLayout>

230
src/scripts/admin-sdk.ts Normal file
View File

@@ -0,0 +1,230 @@
/**
* Admin SDK — typed client + UI helpers for the /admin panel.
*
* Centralises every client-side interaction so the page markup stays declarative:
* - uploadImage: posts a file to /api/admin/upload and returns stored paths
* (no page reload, so an in-progress post draft is preserved).
* - runTassAgent: invokes the TASS feed agent.
* - initTabs / initEditor / initConfirm / initUpload / initTass: wire the DOM.
*/
type UploadResult = { image: string; thumbnail: string };
const UPLOAD_FIELDS = {
file: '#new-image-file',
image: '#new-image-field',
thumb: '#new-thumb-field',
preview: '#new-image-preview',
status: '#upload-status',
button: '#new-upload-btn',
} as const;
const setUploadStatus = (tone: 'idle' | 'busy' | 'ok' | 'err', text: string) => {
const el = document.querySelector<HTMLElement>(UPLOAD_FIELDS.status);
if (!el) return;
el.textContent = text;
el.className = 'text-xs rounded-lg px-2.5 py-1 ' + (
tone === 'busy' ? 'bg-gray-100 text-gray-600' :
tone === 'ok' ? 'bg-green-50 text-green-700' :
tone === 'err' ? 'bg-red-50 text-brand' : 'text-gray-400'
);
};
const renderPreview = (src: string) => {
const el = document.querySelector<HTMLElement>(UPLOAD_FIELDS.preview);
if (!el) return;
el.innerHTML = src
? `<img src="${src}" alt="" class="w-full h-full object-cover" />`
: '<span class="text-gray-400 text-sm">تصویری انتخاب نشده</span>';
};
export const adminSdk = {
async uploadImage(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/admin/upload', { method: 'POST', body: formData });
const data = await response.json().catch(() => ({ error: 'پاسخ نامعتبر سرور' }));
if (!response.ok || data.error) {
throw new Error(data.error ?? 'آپلود ناموفق بود');
}
return data as UploadResult;
},
async runTassAgent(limit = 3): Promise<unknown> {
const response = await fetch('/api/agents/tass', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ limit }),
});
if (!response.ok) {
throw new Error('خطا در اجرای agent');
}
return response.json();
},
initUpload() {
const fileInput = document.querySelector<HTMLInputElement>(UPLOAD_FIELDS.file);
if (!fileInput) return;
const imageField = document.querySelector<HTMLInputElement>(UPLOAD_FIELDS.image);
const thumbField = document.querySelector<HTMLInputElement>(UPLOAD_FIELDS.thumb);
const button = document.querySelector<HTMLButtonElement>(UPLOAD_FIELDS.button);
const syncInsertButtons = () => {
const value = imageField?.value ?? '';
document.querySelectorAll<HTMLButtonElement>('[data-insert-image]').forEach((btn) => {
btn.dataset.insertImage = value;
btn.disabled = !value;
});
};
const applyImage = (image: string, thumbnail: string) => {
if (imageField) imageField.value = image;
if (thumbField) thumbField.value = thumbnail;
renderPreview(thumbnail || image);
syncInsertButtons();
};
const handleFile = async (file: File) => {
setUploadStatus('busy', 'در حال آپلود و پردازش...');
if (button) button.disabled = true;
try {
const result = await adminSdk.uploadImage(file);
applyImage(result.image, result.thumbnail);
setUploadStatus('ok', 'تصویر آپلود شد و آماده استفاده است.');
} catch (error) {
setUploadStatus('err', error instanceof Error ? error.message : 'آپلود ناموفق بود.');
} finally {
if (button) button.disabled = false;
}
};
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (file) handleFile(file);
});
button?.addEventListener('click', () => {
const file = fileInput.files?.[0];
if (file) handleFile(file);
else setUploadStatus('err', 'ابتدا یک فایل انتخاب کنید.');
});
syncInsertButtons();
},
initEditor() {
const editor = document.querySelector<HTMLTextAreaElement>('#post-body-editor');
if (!editor) return;
const insertAtCursor = (value: string) => {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const before = editor.value.slice(0, start);
const after = editor.value.slice(end);
const prefix = before.endsWith('\n') || before.length === 0 ? '' : '\n\n';
const suffix = after.startsWith('\n') || after.length === 0 ? '' : '\n\n';
editor.value = `${before}${prefix}${value}${suffix}${after}`;
editor.focus();
const cursor = before.length + prefix.length + value.length;
editor.setSelectionRange(cursor, cursor);
};
document.querySelectorAll<HTMLButtonElement>('[data-insert-image]').forEach((button) => {
button.addEventListener('click', () => {
const image = button.dataset.insertImage;
if (image) insertAtCursor(`[image:${image}|توضیح تصویر]`);
});
});
document.querySelectorAll<HTMLButtonElement>('[data-editor-command]').forEach((button) => {
button.addEventListener('click', () => {
const command = button.dataset.editorCommand;
if (command === 'heading') insertAtCursor('## تیتر میانی');
if (command === 'quote') insertAtCursor('> متن نقل‌قول');
if (command === 'separator') insertAtCursor('---');
});
});
},
initConfirm() {
document.querySelectorAll<HTMLFormElement>('[data-confirm-form]').forEach((form) => {
form.addEventListener('submit', (event) => {
const message = form.dataset.confirmMessage || 'برای تایید حذف عبارت confirm را تایپ کنید.';
const value = window.prompt(message);
if (value !== 'confirm') {
event.preventDefault();
window.alert('حذف لغو شد. عبارت واردشده باید دقیقاً confirm باشد.');
return;
}
const input = form.querySelector<HTMLInputElement>('[data-confirm-input]');
if (input) input.value = value;
});
});
},
initTass() {
const button = document.querySelector<HTMLButtonElement>('#run-tass-agent');
const result = document.querySelector<HTMLPreElement>('#tass-agent-result');
if (!button || !result) return;
button.addEventListener('click', async () => {
button.disabled = true;
button.textContent = 'در حال اجرا...';
result.classList.remove('hidden');
result.textContent = 'در حال دریافت و ترجمه خبرها...';
try {
const data = await adminSdk.runTassAgent(3);
result.textContent = JSON.stringify(data, null, 2);
} catch (error) {
result.textContent = error instanceof Error ? error.message : 'خطا در اجرای agent';
} finally {
button.disabled = false;
button.textContent = 'اجرای agent و ساخت پست';
}
});
},
initTabs() {
const nav = document.querySelector<HTMLElement>('[data-active-tab]');
const defaultTab = nav?.dataset.activeTab ?? 'pending';
const tabs = Array.from(document.querySelectorAll<HTMLAnchorElement>('[data-tab]'));
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
const activate = (id: string) => {
tabs.forEach((tab) => {
const active = tab.dataset.tab === id;
tab.classList.toggle('bg-brand', active);
tab.classList.toggle('text-white', active);
tab.classList.toggle('border-brand', active);
tab.classList.toggle('bg-white', !active);
tab.classList.toggle('text-gray-700', !active);
tab.classList.toggle('border-gray-200', !active);
if (active) tab.setAttribute('aria-current', 'page');
else tab.removeAttribute('aria-current');
});
panels.forEach((panel) => {
panel.classList.toggle('hidden', panel.dataset.panel !== id);
});
};
activate(defaultTab);
tabs.forEach((tab) => {
tab.addEventListener('click', (event) => {
const id = tab.dataset.tab;
if (!id) return;
event.preventDefault();
activate(id);
const url = new URL(window.location.href);
url.searchParams.set('tab', id);
url.searchParams.delete('pendingPage');
url.searchParams.delete('approvedPage');
url.searchParams.delete('edit');
window.history.pushState({}, '', url);
});
});
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
activate(params.get('tab') || defaultTab);
});
},
};

42
src/styles/global.css Normal file
View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.img-placeholder {
background: linear-gradient(135deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 200%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}