شامل کارهای چند نشست موازی که تا اینجا فقط روی سرور بود: - نقشهٔ دفتر مرکزی در فوتر (LazyOfficeMap) + MapViewport/LazyMaps/mapProjects - صفحهٔ مدیریت مقالات (AdminArticlesPage) + میانبر ویرایش پیشنویس در وبلاگ - مهاجرت ۰۰۰۴: updated_at مقالات/پروژهها - chatbot/Dockerfile: نصب دومرحلهای torch (کاربردی برای PyPI فیلترشده) - seed: خالیکردن آمارهای دموی story - docker-compose: اصلاح پسوند @file میانافزارهای Traefik - حذف فایل موقت frontend/.tmp-style.json و اصلاحات جزئی UI با این کامیت، آنچه روی سرور اجرا میشود Exactly همان چیزی است که در مخزن ثبت شده.
131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import React from 'react'
|
|
import { ArrowLeftIcon } from 'lucide-react'
|
|
import { Reveal } from '../Reveal'
|
|
import { ButtonLink } from '../Button'
|
|
import { useSite } from '../../api'
|
|
import { useLang } from '../../i18n/lang'
|
|
import { useFieldSaver } from '../../admin/AdminContext'
|
|
import { InlineEdit } from '../admin/InlineEdit'
|
|
import { SingletonPill } from '../../admin/Editable'
|
|
|
|
type StoryProps = {
|
|
showLink?: boolean;
|
|
showLabel?: boolean;
|
|
showQuote?: boolean;
|
|
alignTop?: boolean;
|
|
children?: React.ReactNode;
|
|
/** پسزمینه: black = char (تیره) و gray = ink (روشنتر) — ریتم تناوب بخشها */
|
|
tone?: 'black' | 'gray';
|
|
}
|
|
|
|
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, tone = 'gray', children }: StoryProps) {
|
|
const { story } = useSite()
|
|
const { href } = useLang()
|
|
const save = useFieldSaver()
|
|
|
|
const saveStory = (field: string, value: unknown) =>
|
|
save('story', story.id, { [field]: value }, (d) => {
|
|
;(d.story as unknown as Record<string, unknown>)[field] = value
|
|
})
|
|
|
|
const saveParagraph = (index: number, value: string) => {
|
|
const paragraphs = [...story.paragraphs]
|
|
paragraphs[index] = value
|
|
void saveStory('paragraphs', paragraphs)
|
|
}
|
|
|
|
return (
|
|
<section id="story" className={`${tone === 'black' ? 'bg-char' : 'bg-ink'} py-24 lg:py-32`} aria-labelledby="story-title">
|
|
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
|
|
<div>
|
|
{showLabel && (
|
|
<Reveal>
|
|
<h2 id="story-title" className="text-section font-bold text-ivory">
|
|
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
|
</h2>
|
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
|
</Reveal>
|
|
)}
|
|
|
|
<Reveal delay={0.08}>
|
|
{story.paragraphs.map((paragraph, i) => (
|
|
<p key={i} className={`text-base font-light leading-9 text-ivory/65 text-justify ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
|
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
|
</p>
|
|
))}
|
|
</Reveal>
|
|
|
|
{/* آمارها فقط وقتی نمایش داده میشوند که مقداری ثبت شده باشد —
|
|
با خالیبودن، کادر خالی با خط جداکننده بیمعنا میماند؛
|
|
ویرایش/افزودن آمار از پنل ادمین (موجودیت story) انجام میشود */}
|
|
{(story.stat1_value || story.stat2_value) && (
|
|
<Reveal delay={0.16}>
|
|
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
|
<div>
|
|
<p className="text-2xl font-bold text-copper">
|
|
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
|
</p>
|
|
<p className="mt-2 text-sm font-light text-ivory/55">
|
|
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-copper">
|
|
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
|
</p>
|
|
<p className="mt-2 text-sm font-light text-ivory/55">
|
|
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
)}
|
|
|
|
{showQuote && story.quote && (
|
|
<Reveal delay={0.2}>
|
|
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
|
<p className="text-sm font-light leading-8 text-ivory/80">
|
|
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
|
|
</p>
|
|
<p className="mt-5 text-sm font-bold text-copper">
|
|
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
|
|
</p>
|
|
</div>
|
|
</Reveal>
|
|
)}
|
|
|
|
{showLink && (
|
|
<Reveal delay={0.22}>
|
|
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
|
</ButtonLink>
|
|
</Reveal>
|
|
)}
|
|
|
|
{children && (
|
|
<Reveal delay={0.24}>
|
|
{children}
|
|
</Reveal>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative grid grid-cols-2 gap-4">
|
|
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
|
|
{story.bento.map((item, i) => (
|
|
<Reveal key={item.src + i} delay={0.06 * i}>
|
|
<div className="overflow-hidden">
|
|
<img
|
|
src={item.src}
|
|
alt={item.alt}
|
|
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105" loading="lazy" decoding="async"
|
|
/>
|
|
</div>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|