شامل کارهای چند نشست موازی که تا اینجا فقط روی سرور بود: - نقشهٔ دفتر مرکزی در فوتر (LazyOfficeMap) + MapViewport/LazyMaps/mapProjects - صفحهٔ مدیریت مقالات (AdminArticlesPage) + میانبر ویرایش پیشنویس در وبلاگ - مهاجرت ۰۰۰۴: updated_at مقالات/پروژهها - chatbot/Dockerfile: نصب دومرحلهای torch (کاربردی برای PyPI فیلترشده) - seed: خالیکردن آمارهای دموی story - docker-compose: اصلاح پسوند @file میانافزارهای Traefik - حذف فایل موقت frontend/.tmp-style.json و اصلاحات جزئی UI با این کامیت، آنچه روی سرور اجرا میشود Exactly همان چیزی است که در مخزن ثبت شده.
164 lines
7.3 KiB
TypeScript
164 lines
7.3 KiB
TypeScript
import React from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
|
import { PageHero } from '../components/PageHero'
|
|
import { Reveal } from '../components/Reveal'
|
|
import { ArticleCard } from '../components/ArticleCard'
|
|
import { Newsletter } from '../components/home/Newsletter'
|
|
import { useSite } from '../api'
|
|
import { useLang } from '../i18n/lang'
|
|
import { EditableItem } from '../admin/Editable'
|
|
import { useAdmin } from '../admin/AdminContext'
|
|
import { formatJalali, formatEnDate } from '../utils/date'
|
|
import { toPersianDigits } from '../utils/digits'
|
|
import { PlusIcon } from 'lucide-react'
|
|
|
|
function DraftBadge({ className = '' }: { className?: string }) {
|
|
const { t } = useLang()
|
|
return (
|
|
<span
|
|
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
|
|
>
|
|
{t('draft')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** میانبر ویرایش پیشنویس برای هر کارمند واردشده — creator هم بدون حالت ویرایش ادمین */
|
|
function DraftEditLink({ id, className = '' }: { id: number; className?: string }) {
|
|
const { lang } = useLang()
|
|
const { syncEditLang } = useAdmin()
|
|
return (
|
|
<Link
|
|
to={`/admin/write/${id}`}
|
|
onClick={() => {
|
|
// ویرایشگر با زبان همین صفحه باز شود — کلیک از وبلاگ انگلیسی نباید
|
|
// مقاله را در حالت فارسی باز کند (و «دیدن در سایت» را به وبلاگ فارسی ببرد)
|
|
syncEditLang(lang)
|
|
localStorage.setItem(`aramesh_write_lang_${id}`, lang)
|
|
}}
|
|
className={`border border-copper/60 bg-ink/90 px-2.5 py-1 text-xs font-bold text-copper backdrop-blur transition-colors duration-200 hover:bg-copper hover:text-char ${className}`}
|
|
>
|
|
{lang === 'en' ? 'Edit draft' : 'ویرایش پیشنویس'}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function Blog() {
|
|
const { articles } = useSite()
|
|
const { t, href, lang } = useLang()
|
|
const { isEditMode, isAuthenticated } = useAdmin()
|
|
|
|
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده میشود
|
|
const writeButton = isEditMode ? (
|
|
<Link
|
|
to="/admin/write"
|
|
data-admin-link
|
|
className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
|
>
|
|
<PlusIcon className="h-4 w-4" />
|
|
{t('writeNewArticle')}
|
|
</Link>
|
|
) : null
|
|
|
|
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
|
|
if (articles.length === 0) {
|
|
return (
|
|
<>
|
|
<PageHero pageKey="blog" />
|
|
<section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
|
|
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
|
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
|
|
<p className="mt-4 text-sm font-light text-ivory/60">
|
|
{t('blogEmptyText')}
|
|
</p>
|
|
<div className="mt-8 flex justify-center">{writeButton}</div>
|
|
</div>
|
|
</section>
|
|
<Newsletter />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const [featured, ...rest] = articles
|
|
|
|
return (
|
|
<>
|
|
<PageHero pageKey="blog" />
|
|
|
|
<section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
|
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
|
<div className="mb-10 flex justify-start">{writeButton}</div>
|
|
<Reveal>
|
|
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
|
<div className="relative">
|
|
<Link
|
|
to={href(`/blog/${encodeURIComponent(lang === 'en' && featured.slug_en ? featured.slug_en : featured.slug)}`)}
|
|
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
|
aria-label={`${t('readArticlePrefix')} ${featured.title}`}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<img
|
|
src={featured.image}
|
|
alt={`${t('articleImagePrefix')} ${featured.title}`}
|
|
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" fetchPriority="high" decoding="async"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col justify-center p-8 lg:p-12">
|
|
<p className="text-xs font-light text-copper">
|
|
{t('featuredArticle')} — {featured.category}
|
|
{featured.status === 'draft' && (
|
|
<DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" />
|
|
)}
|
|
</p>
|
|
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
|
{featured.title}
|
|
</h2>
|
|
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
|
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
|
<span className="flex items-center gap-4">
|
|
<span>{lang === 'en' ? formatEnDate(featured.date) : formatJalali(featured.date)}</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<ClockIcon className="h-3.5 w-3.5" />
|
|
{lang === 'en' ? featured.read : toPersianDigits(featured.read)}
|
|
</span>
|
|
</span>
|
|
<span className="flex items-center gap-2 text-copper">
|
|
{t('readMore')}
|
|
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-200 ease-lux rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
{isAuthenticated && featured.status === 'draft' && (
|
|
<DraftEditLink id={featured.id} className="absolute top-4 z-10 ltr:right-4 rtl:left-4" />
|
|
)}
|
|
</div>
|
|
</EditableItem>
|
|
</Reveal>
|
|
|
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
|
{rest.map((article, i) => (
|
|
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
|
<div className="relative h-full">
|
|
{article.status === 'draft' && (
|
|
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" />
|
|
)}
|
|
{isAuthenticated && article.status === 'draft' && (
|
|
<DraftEditLink id={article.id} className="absolute top-3 z-20 ltr:right-3 rtl:left-3" />
|
|
)}
|
|
<ArticleCard article={article} />
|
|
</div>
|
|
</EditableItem>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<Newsletter />
|
|
</>
|
|
)
|
|
}
|