خانهٔ اپ خبری
پیشنمایش تمامصفحهسلام و جستوجو و اعلان، دستهها با چیپهای اسکرولخور، خبرِ ویژه با عکسِ تمامعرض و تیترِ سفید (اسکرولِ قفلشونده با نشانگر)، تازهترینها در ردیفهای جمعوجور با ذخیره، بحثِ روز و ناوبریِ پایین.
۱. نصب کامپوننتها
این بلاک از ۷ آیتم دیگ استفاده میکند: avatarbottom-navigationbuttoncontrol-sizeiconspersiantoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/bottom-navigation.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/bottom-navigation.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/bottom-navigation.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/bottom-navigation.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
mobile-news.tsx
"use client"
import * as React from "react"
import { BellIcon, ChatIcon, ClockIcon, DashboardIcon, HeartIcon, SearchIcon, ShareIcon, UserIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { BottomNavigation, BottomNavigationItem } from "@/components/ui/bottom-navigation"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"
const CATEGORIES = ["برای تو", "اقتصاد", "فناوری", "سلامت", "ورزش", "فرهنگ"]
const FEATURED: { id: string; category: string; title: string; author: string; photo: string; minutes: number; image: StockImageName }[] = [
{ id: "f1", category: "اقتصاد", title: "بازارِ طلا در هفتهای که دلار آرام گرفت؛ حالا چه کنیم؟", author: "نگار احمدی", photo: stockImages.personWoman3.src, minutes: 4, image: "coverFinanceDesk" },
{ id: "f2", category: "فناوری", title: "اپهای بانکی چطور بیصدا امنتر شدند", author: "امیر رضایی", photo: stockImages.personMan1.src, minutes: 6, image: "projectMobileApp" },
{ id: "f3", category: "سلامت", title: "پیادهرویِ روزانهٔ سی دقیقهای، از دیدِ پزشکان", author: "سارا محمدی", photo: stockImages.personWoman2.src, minutes: 3, image: "gym" },
]
const LATEST: { id: string; category: string; title: string; author: string; photo: string; hours: number; minutes: number; image: StockImageName }[] = [
{ id: "n1", category: "اقتصاد", title: "قسطِ وامهای خرد از مهر کم میشود", author: "علی کاظمی", photo: stockImages.personMan2.src, hours: 1, minutes: 3, image: "coverCalculator" },
{ id: "n2", category: "فرهنگ", title: "کافههایی که کتابفروشی هم شدند", author: "نگار احمدی", photo: stockImages.personWoman3.src, hours: 3, minutes: 5, image: "restaurant" },
{ id: "n3", category: "فناوری", title: "داشبوردهای سادهتر، تصمیمهای سریعتر", author: "امیر رضایی", photo: stockImages.personMan1.src, hours: 5, minutes: 4, image: "projectDashboard" },
{ id: "n4", category: "سلامت", title: "خوابِ کافی و تمرکز سرِ کار؛ پژوهشِ تازه", author: "سارا محمدی", photo: stockImages.personWoman2.src, hours: 8, minutes: 6, image: "studentStudy" },
]
/**
* خانهٔ اپِ خبری: سلام و دکمههای جستوجو و اعلان، دستهها با چیپهای اسکرولخور، خبرِ ویژه
* با عکسِ تمامعرض و تیترِ سفید روی سایهروشن (اسکرولِ افقیِ قفلشونده با نشانگر)، و
* «تازهترینها» در ردیفهای جمعوجور با عکسِ کوچک، نویسنده، زمان و ذخیره. ناوبریِ پایین.
*/
export default function MobileNewsBlock() {
const [category, setCategory] = React.useState(CATEGORIES[0])
const [slide, setSlide] = React.useState(0)
const [saved, setSaved] = React.useState<string[]>(["n2"])
const [tab, setTab] = React.useState("home")
const latest = category === CATEGORIES[0] ? LATEST : LATEST.filter((item) => item.category === category)
return (
<ControlSizeProvider size="xl" asChild>
<div className="mx-auto flex min-h-svh w-full max-w-md flex-col bg-background">
<header className="flex items-center gap-3 px-4 pt-4 pb-2">
<Avatar alt="آرمیتا رستگار" src={stockImages.personWoman1.src} className="size-11" />
<span className="flex flex-1 flex-col">
<span className="text-xs text-muted-foreground">صبح بخیر</span>
<span className="font-bold">آرمیتا رستگار</span>
</span>
<Button variant="outline" size="icon" aria-label="جستوجو" className="rounded-full">
<SearchIcon />
</Button>
<Button variant="outline" size="icon" aria-label="اعلانها، ۳ خواندهنشده" className="relative rounded-full">
<BellIcon />
<span aria-hidden className="absolute end-2.5 top-2.5 size-2 rounded-full bg-[var(--dig-orange-500)] ring-2 ring-background" />
</Button>
</header>
<main className="flex flex-1 flex-col gap-6 pb-6">
<h1 className="px-4 text-2xl leading-snug font-black">تازهترین خبرها</h1>
{/* دستهها: یک انتخاب، اسکرولِ افقی؛ تا لبهٔ صفحه میرود تا ادامهدار بودن پیدا باشد. */}
<div className="overflow-x-auto px-4 [scrollbar-width:none]">
<ToggleGroup type="single" variant="outline" separated value={category} onValueChange={(value) => value && setCategory(value as string)} aria-label="دستهها" className="w-max gap-2">
{CATEGORIES.map((item) => (
<ToggleGroupItem key={item} value={item} className="rounded-full! border-transparent bg-muted px-4 font-semibold shadow-none data-[state=on]:bg-foreground data-[state=on]:text-background">
{item}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{/* خبرِ ویژه: کارتهای تمامعرض با اسکرولِ قفلشونده؛ لبهٔ کارتِ بعدی پیداست. */}
<section aria-label="خبرهای ویژه" className="flex flex-col gap-3">
<div
className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-4 px-4 [scrollbar-width:none]"
onScroll={(event) => {
const element = event.currentTarget
setSlide(Math.round(Math.abs(element.scrollLeft) / (element.clientWidth * 0.86)))
}}
>
{FEATURED.map((item) => (
<article key={item.id} className="relative aspect-[4/5] w-[86%] shrink-0 snap-start overflow-hidden rounded-xl bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img src={stockImages[item.image].src} alt="" className="absolute inset-0 size-full object-cover" />
<span aria-hidden className="absolute inset-0 bg-linear-to-t from-[var(--dig-slate-950)] via-[color-mix(in_oklab,var(--dig-slate-950)_40%,transparent)] via-45% to-transparent" />
<span className="absolute start-4 top-4 rounded-full bg-[var(--dig-white)] px-3 py-1 text-xs font-bold text-[var(--dig-slate-900)]">{item.category}</span>
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-3 p-4 text-[var(--dig-white)]">
<h2 className="text-xl leading-snug font-bold text-balance">
<a href="#" className="after:absolute after:inset-0">
{item.title}
</a>
</h2>
<span className="flex items-center gap-2 text-xs text-[var(--dig-slate-200)]">
<Avatar size="sm" alt="" src={item.photo} />
{item.author}
<span aria-hidden>·</span>
{toFaDigits(item.minutes)} دقیقه مطالعه
</span>
</div>
</article>
))}
</div>
<div aria-hidden className="flex justify-center gap-1.5">
{FEATURED.map((item, index) => (
<span key={item.id} className={`h-1.5 rounded-full transition-all ${index === slide ? "w-5 bg-foreground" : "w-1.5 bg-input"}`} />
))}
</div>
</section>
<section aria-labelledby="latest-title" className="flex flex-col gap-2 px-4">
<div className="flex items-center justify-between">
<h2 id="latest-title" className="text-lg font-bold">
تازهترینها
</h2>
<Button variant="ghost" className="-me-3 text-sm">
همه
</Button>
</div>
{latest.length === 0 ? (
<p className="rounded-xl bg-subtle p-6 text-center text-sm text-muted-foreground">در این دسته فعلاً خبرِ تازهای نیست.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{latest.map((item) => {
const isSaved = saved.includes(item.id)
return (
<li key={item.id} className="relative flex gap-3 py-3">
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img src={stockImages[item.image].src} alt="" loading="lazy" className="size-20 shrink-0 rounded-lg object-cover" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-xs font-semibold text-brand-secondary">{item.category}</span>
<h3 className="line-clamp-2 text-[0.9375rem] leading-6 font-bold">
<a href="#" className="after:absolute after:inset-0">
{item.title}
</a>
</h3>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground [&>svg]:size-3.5">
<ClockIcon aria-hidden />
{toFaDigits(item.hours)} ساعت پیش
<span aria-hidden>·</span>
{toFaDigits(item.minutes)} دقیقه
</span>
</div>
<ControlSizeProvider size="lg" asChild>
<Button
variant="ghost"
size="icon"
aria-pressed={isSaved}
aria-label={isSaved ? `برداشتنِ «${item.title}» از ذخیرهها` : `ذخیرهٔ «${item.title}»`}
onClick={() => setSaved((list) => (isSaved ? list.filter((id) => id !== item.id) : [...list, item.id]))}
className={`relative z-10 -me-2 shrink-0 self-start ${isSaved ? "text-[var(--dig-pink-600)] hover:text-[var(--dig-pink-600)]" : ""}`}
>
<HeartIcon filled={isSaved} />
</Button>
</ControlSizeProvider>
</li>
)
})}
</ul>
)}
</section>
{/* بحثِ روز: نقلقول با شمارِ واکنشها، تنوعی میانِ فهرستِ عکسدار. */}
<section aria-label="بحثِ روز" className="mx-4 flex flex-col gap-3 rounded-xl bg-[color-mix(in_oklab,var(--dig-violet-500)_8%,var(--card))] p-4">
<span className="text-xs font-bold text-[var(--dig-violet-700)] dark:text-[var(--dig-violet-300)]">بحثِ روز</span>
<p className="leading-7 font-semibold">«پساندازِ کوچک ولی منظم، از سرمایهگذاریِ بزرگِ یکباره بهتر جواب میدهد.»</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground [&_svg]:size-4">
<span className="flex items-center gap-1.5">
<HeartIcon aria-hidden />
{toFaDigits("۱٫۲")} هزار
</span>
<span className="flex items-center gap-1.5">
<ChatIcon aria-hidden />
{toFaDigits(86)} نظر
</span>
<Button variant="ghost" size="icon" aria-label="اشتراکگذاری" className="ms-auto -me-2">
<ShareIcon />
</Button>
</div>
</section>
</main>
<BottomNavigation value={tab} onValueChange={setTab} indicator="pill" fixed={false} className="sticky bottom-0">
<BottomNavigationItem value="home" icon={<DashboardIcon />} label="خانه" />
<BottomNavigationItem value="explore" icon={<SearchIcon />} label="کاوش" />
<BottomNavigationItem value="saved" icon={<HeartIcon />} label="ذخیرهها" />
<BottomNavigationItem value="profile" icon={<UserIcon />} label="پروفایل" />
</BottomNavigation>
</div>
</ControlSizeProvider>
)
}