fix
This commit is contained in:
84
src/components/Footer.astro
Normal file
84
src/components/Footer.astro
Normal 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
130
src/components/Header.astro
Normal 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>
|
||||
55
src/components/Pagination.astro
Normal file
55
src/components/Pagination.astro
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
src/components/PostCard.astro
Normal file
46
src/components/PostCard.astro
Normal 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>
|
||||
53
src/components/admin/AdminPager.astro
Normal file
53
src/components/admin/AdminPager.astro
Normal 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
257
src/data/posts.ts
Normal 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
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
32
src/layouts/BaseLayout.astro
Normal file
32
src/layouts/BaseLayout.astro
Normal 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
39
src/lib/adminAuth.ts
Normal 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
19
src/lib/apiAuth.ts
Normal 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
294
src/lib/cms.ts
Normal 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
107
src/lib/date.ts
Normal 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
75
src/lib/images.ts
Normal 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
66
src/lib/publisher.ts
Normal 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
242
src/lib/puppeteerScraper.ts
Normal 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
351
src/lib/tassAgent.ts
Normal 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
24
src/pages/[page].astro
Normal 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
317
src/pages/admin/index.astro
Normal 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>
|
||||
39
src/pages/admin/login.astro
Normal file
39
src/pages/admin/login.astro
Normal 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>
|
||||
16
src/pages/api/admin/login.ts
Normal file
16
src/pages/api/admin/login.ts
Normal 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);
|
||||
};
|
||||
8
src/pages/api/admin/logout.ts
Normal file
8
src/pages/api/admin/logout.ts
Normal 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
172
src/pages/api/admin/save.ts
Normal 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);
|
||||
};
|
||||
25
src/pages/api/admin/upload.ts
Normal file
25
src/pages/api/admin/upload.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
30
src/pages/api/agents/tass.ts
Normal file
30
src/pages/api/agents/tass.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
46
src/pages/api/posts/publish.ts
Normal file
46
src/pages/api/posts/publish.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
28
src/pages/categories/[category].astro
Normal file
28
src/pages/categories/[category].astro
Normal 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
140
src/pages/index.astro
Normal 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>
|
||||
87
src/pages/posts/[slug].astro
Normal file
87
src/pages/posts/[slug].astro
Normal 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>
|
||||
27
src/pages/posts/index.astro
Normal file
27
src/pages/posts/index.astro
Normal 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>
|
||||
62
src/pages/search/index.astro
Normal file
62
src/pages/search/index.astro
Normal 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
230
src/scripts/admin-sdk.ts
Normal 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
42
src/styles/global.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user