Initial commit

This commit is contained in:
2026-10-08 19:51:09 +03:30
commit a133dc43bf
60 changed files with 2779 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { ArrowLeft, Clock3 } from "lucide-react";
import { Link } from "react-router";
import type { Article } from "@/api/mockData";
import SafeImage from "./SafeImage";
export default function ArticleCard({ article, featured = false }: { article: Article; featured?: boolean }) {
return (
<article className={`group flex h-full overflow-hidden rounded-[24px] border border-slate-200 bg-white transition duration-300 hover:-translate-y-1 hover:shadow-[0_18px_50px_rgba(42,36,91,.10)] ${featured ? "sm:grid sm:grid-cols-[1.05fr_.95fr]" : "flex-col"}`}>
<Link to={`/magazine/articles/${article.slug}`} className={`relative block shrink-0 overflow-hidden bg-[#eeecff] ${featured ? "min-h-[280px]" : "aspect-[16/10] w-full"}`}>
<SafeImage src={article.image} alt={article.imageAlt} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
<span className={`absolute right-4 top-4 rounded-full px-3 py-1.5 text-xs font-black ${article.color}`}>{article.category}</span>
</Link>
<div className={`flex min-w-0 flex-1 flex-col ${featured ? "justify-center p-6 sm:p-8" : "p-5"}`}>
<div className="mb-3 flex flex-wrap items-center gap-2.5 text-xs font-bold text-slate-400">
<span>{article.grade}</span><span className="size-1 rounded-full bg-slate-300" /><span className="inline-flex items-center gap-1"><Clock3 size={13} /> {article.readTime}</span>
</div>
<h3 className={`${featured ? "text-2xl" : "line-clamp-2 min-h-[3.7rem] text-lg"} font-black leading-[1.65] text-slate-900 transition group-hover:text-[#5146c7]`}>
<Link to={`/magazine/articles/${article.slug}`}>{article.title}</Link>
</h3>
<p className={`mt-2 line-clamp-2 text-sm leading-7 text-slate-500 ${featured ? "" : "min-h-14"}`}>{article.excerpt}</p>
<Link to={`/magazine/articles/${article.slug}`} className="mt-auto inline-flex items-center gap-1.5 pt-5 text-sm font-black text-[#5b50d6]">
ادامه مطلب <ArrowLeft size={16} className="transition group-hover:-translate-x-1" />
</Link>
</div>
</article>
);
}

View File

@@ -0,0 +1,12 @@
import { Link } from "react-router";
import type { Article } from "@/api/mockData";
import ArticleGrid from "./ArticleGrid";
import SafeImage from "./SafeImage";
export function RelatedArticles({ articles }: { articles: Article[] }) {
return <ArticleGrid articles={articles.slice(0, 3)} />;
}
export function RecommendedArticles({ articles }: { articles: Article[] }) {
return <div className="grid gap-4 lg:grid-cols-3">{articles.slice(0, 3).map((item) => <Link key={item.slug} to={`/magazine/articles/${item.slug}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"><SafeImage src={item.image} alt={item.imageAlt} className="size-20 shrink-0 rounded-xl object-cover" /><span><small className="font-bold text-[#5b50d6]">{item.category}</small><strong className="mt-1 line-clamp-2 block text-sm leading-6 text-slate-900">{item.title}</strong></span></Link>)}</div>;
}

View File

@@ -0,0 +1,6 @@
import type { Article } from "@/api/mockData";
import ArticleCard from "./ArticleCard";
export default function ArticleGrid({ articles }: { articles: Article[] }) {
return <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">{articles.map((article) => <ArticleCard key={article.slug} article={article} />)}</div>;
}

View File

@@ -0,0 +1,11 @@
import { BookOpen } from "lucide-react";
import { Link } from "react-router";
export default function BrandLogo({ inverse = false }: { inverse?: boolean }) {
return (
<Link to="/" className="group flex items-center gap-2.5" aria-label="تدریسینو، صفحه اصلی">
<span className="grid size-10 rotate-2 place-items-center rounded-[13px] bg-[#5b50d6] text-white shadow-[0_7px_0_#dcd9ff] transition-transform group-hover:-rotate-2"><BookOpen size={22} strokeWidth={2.2} /></span>
<span><strong className={`block text-xl font-black leading-5 ${inverse ? "text-white" : "text-slate-900"}`}>تدریسینو</strong><span className="text-[10px] font-bold tracking-tight text-slate-400">مجله یادگیری بهتر</span></span>
</Link>
);
}

View File

@@ -0,0 +1,8 @@
import { ChevronLeft } from "lucide-react";
import { Link } from "react-router";
export type BreadcrumbItem = { label: string; to?: string };
export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
return <nav className="flex flex-wrap items-center justify-center gap-2 text-xs font-bold text-slate-400" aria-label="مسیر صفحه">{items.map((item, index) => <span key={item.label} className="inline-flex items-center gap-2">{item.to ? <Link to={item.to} className="hover:text-[#5b50d6]">{item.label}</Link> : <span>{item.label}</span>}{index < items.length - 1 && <ChevronLeft size={13} />}</span>)}</nav>;
}

View File

@@ -0,0 +1,21 @@
import { Search } from "lucide-react";
export function SearchBar({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return <label className="flex max-w-2xl items-center gap-3 rounded-2xl border border-white bg-white px-4 py-3 shadow-sm focus-within:border-[#8e85eb]"><Search size={20} className="text-slate-400" /><input value={value} onChange={(event) => onChange(event.target.value)} placeholder="دنبال چه موضوعی می‌گردی؟" className="w-full bg-transparent text-sm outline-none" /></label>;
}
export function CategoryFilter({ items, value, onChange }: { items: string[]; value: string; onChange: (value: string) => void }) {
return <div className="no-scrollbar flex gap-2 overflow-x-auto pb-4">{items.map((item) => <button key={item} onClick={() => onChange(item)} className={`shrink-0 rounded-full px-4 py-2 text-sm font-bold transition ${value === item ? "bg-[#5b50d6] text-white" : "border border-slate-200 bg-white text-slate-600 hover:border-[#8e85eb]"}`}>{item}</button>)}</div>;
}
function SelectFilter({ label, value, items, onChange }: { label: string; value: string; items: { value: string; label: string }[]; onChange: (value: string) => void }) {
return <label><span className="mb-2 block text-xs font-black text-slate-500">{label}</span><select value={value} onChange={(event) => onChange(event.target.value)} className="w-full rounded-xl border border-slate-200 bg-[#fbfaf7] px-3 py-2.5 text-sm font-bold text-slate-700 outline-none focus:border-[#8e85eb]">{items.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>;
}
export function GradeFilter({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return <SelectFilter label="پایه تحصیلی" value={value} onChange={onChange} items={["همه پایه‌ها", "ابتدایی", "متوسطه اول", "متوسطه دوم"].map((item) => ({ value: item, label: item }))} />;
}
export function SortFilter({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return <SelectFilter label="مرتب‌سازی" value={value} onChange={onChange} items={[{ value: "newest", label: "جدیدترین" }, { value: "popular", label: "پربازدیدترین" }, { value: "shortest", label: "کوتاه‌ترین زمان مطالعه" }]} />;
}

17
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { AtSign, Send } from "lucide-react";
import { Link } from "react-router";
import BrandLogo from "./BrandLogo";
import { navItems } from "./Header";
export default function Footer() {
return (
<footer className="mt-20 bg-[#171a2b] text-white">
<div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:px-6 md:grid-cols-2 lg:grid-cols-[1.5fr_1fr_1fr] lg:px-8">
<div><BrandLogo inverse /><p className="mt-5 max-w-md text-sm leading-7 text-slate-400">تدریسینو یک مجله آموزشی رایگان برای دانش‌آموزانی است که می‌خواهند هوشمندانه‌تر یاد بگیرند، نه سخت‌تر.</p><div className="mt-5 flex gap-2"><a href="#" aria-label="اینستاگرام" className="grid size-10 place-items-center rounded-xl bg-white/8 text-slate-300 hover:bg-[#5b50d6] hover:text-white"><AtSign size={19} /></a><a href="#" aria-label="تلگرام" className="grid size-10 place-items-center rounded-xl bg-white/8 text-slate-300 hover:bg-[#5b50d6] hover:text-white"><Send size={18} /></a></div></div>
<div><h3 className="mb-4 font-black">دسترسی سریع</h3><div className="flex flex-col gap-3 text-sm text-slate-400">{navItems.map((item) => <Link key={item.to} to={item.to} className="hover:text-white">{item.label}</Link>)}</div></div>
<div><h3 className="mb-4 font-black">موضوعات محبوب</h3><div className="flex flex-col gap-3 text-sm text-slate-400"><Link to="/magazine?category=مهارت مطالعه" className="hover:text-white">مهارت‌های مطالعه</Link><Link to="/magazine?category=امتحانات" className="hover:text-white">امتحانات و کنکور</Link><Link to="/magazine?category=علوم" className="hover:text-white">علوم و فناوری</Link></div></div>
</div>
<div className="border-t border-white/8"><div className="mx-auto flex max-w-7xl flex-col gap-2 px-4 py-5 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8"><p>© ۱۴۰۴ تدریسینو؛ یادگیری برای همه.</p><p>با کنجکاوی ساخته شده برای دانش‌آموزان ایران</p></div></div>
</footer>
);
}

29
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { useState } from "react";
import { ArrowLeft, Menu, Search, X } from "lucide-react";
import { Link, NavLink } from "react-router";
import BrandLogo from "./BrandLogo";
export const navItems = [
{ to: "/", label: "خانه", end: true },
{ to: "/magazine", label: "مجله" },
{ to: "/grades", label: "پایه‌های تحصیلی" },
{ to: "/about", label: "درباره ما" },
];
export default function Header() {
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="sticky top-0 z-50 border-b border-slate-200/70 bg-[#fbfaf7]/90 backdrop-blur-xl">
<div className="mx-auto flex h-[76px] max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<BrandLogo />
<nav className="hidden items-center gap-1 lg:flex" aria-label="منوی اصلی">{navItems.map((item) => <NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `rounded-xl px-4 py-2.5 text-sm font-bold transition-colors ${isActive ? "bg-[#eeecff] text-[#5146c7]" : "text-slate-600 hover:bg-white hover:text-slate-950"}`}>{item.label}</NavLink>)}</nav>
<div className="hidden items-center gap-2 lg:flex">
<Link to="/magazine" className="grid size-10 place-items-center rounded-xl border border-slate-200 bg-white text-slate-600 transition hover:border-[#5b50d6] hover:text-[#5b50d6]" aria-label="جستجو در مجله"><Search size={19} /></Link>
<Link to="/magazine" className="inline-flex items-center gap-2 rounded-xl bg-[#ffd05b] px-4 py-2.5 text-sm font-black text-slate-900 shadow-[0_4px_0_#dcae3f] transition hover:-translate-y-0.5">شروع مطالعه <ArrowLeft size={17} /></Link>
</div>
<button className="grid size-10 place-items-center rounded-xl border border-slate-200 bg-white lg:hidden" onClick={() => setMenuOpen((value) => !value)} aria-label={menuOpen ? "بستن منو" : "باز کردن منو"}>{menuOpen ? <X size={21} /> : <Menu size={21} />}</button>
</div>
{menuOpen && <nav className="border-t border-slate-200 bg-white px-4 py-3 lg:hidden"><div className="mx-auto flex max-w-7xl flex-col gap-1">{navItems.map((item) => <NavLink key={item.to} to={item.to} end={item.end} onClick={() => setMenuOpen(false)} className={({ isActive }) => `rounded-xl px-4 py-3 text-sm font-bold ${isActive ? "bg-[#eeecff] text-[#5146c7]" : "text-slate-700"}`}>{item.label}</NavLink>)}</div></nav>}
</header>
);
}

23
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { ArrowLeft, Check, GraduationCap, Lightbulb, Monitor, PenLine, Sparkles } from "lucide-react";
import { Link } from "react-router";
export default function Hero() {
return (
<section className="relative overflow-hidden border-b border-slate-200/70">
<div className="hero-grid absolute inset-0 opacity-50" /><div className="absolute -right-24 top-24 size-72 rounded-full bg-[#e5e1ff] blur-3xl" /><div className="absolute -left-20 bottom-0 size-72 rounded-full bg-[#fff0ba] blur-3xl" />
<div className="relative mx-auto grid max-w-7xl items-center gap-12 px-4 py-14 sm:px-6 md:py-20 lg:grid-cols-[1.02fr_.98fr] lg:px-8 lg:py-24">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-[#d8d3ff] bg-white px-3.5 py-2 text-xs font-black text-[#5b50d6] shadow-sm"><Sparkles size={15} className="text-amber-500" />یادگیری ساده‌تر، آینده روشن‌تر</span>
<h1 className="mt-6 max-w-2xl text-4xl font-black leading-[1.45] tracking-tight text-slate-950 sm:text-5xl lg:text-[58px]">مجله آموزشی <span className="relative mr-3 inline-block text-[#5b50d6]">تدریسینو<svg className="absolute -bottom-1 right-0 h-3 w-full text-[#ffd05b]" viewBox="0 0 190 12" fill="none" aria-hidden="true"><path d="M3 8.5C44 2 125 2 187 7" stroke="currentColor" strokeWidth="6" strokeLinecap="round"/></svg></span></h1>
<p className="mt-6 max-w-xl text-base leading-8 text-slate-600 sm:text-lg">مطالب آموزشی، نکات درسی و محتوای کاربردی برای مسیر بهتر یادگیری</p>
<div className="mt-8 flex flex-wrap gap-3"><Link to="/magazine" className="inline-flex items-center gap-2 rounded-2xl bg-[#5b50d6] px-6 py-3.5 text-sm font-black text-white shadow-[0_6px_0_#4038a8] transition hover:-translate-y-0.5">مطالعه مطالب <ArrowLeft size={18} /></Link><Link to="/grades" className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-6 py-3.5 text-sm font-black text-slate-800 shadow-sm transition hover:border-[#5b50d6]">انتخاب پایه تحصیلی <GraduationCap size={19} /></Link></div>
<div className="mt-9 flex items-center gap-6 border-t border-slate-200 pt-6"><div><strong className="block text-xl font-black text-slate-900">+۱۲۰</strong><span className="text-xs text-slate-500">مقاله کاربردی</span></div><div className="h-9 w-px bg-slate-200" /><div><strong className="block text-xl font-black text-slate-900">۱۲ پایه</strong><span className="text-xs text-slate-500">محتوای متنوع</span></div><div className="h-9 w-px bg-slate-200" /><div><strong className="block text-xl font-black text-slate-900">رایگان</strong><span className="text-xs text-slate-500">برای همه</span></div></div>
</div>
<div className="relative mx-auto w-full max-w-[540px] lg:mx-0" role="img" aria-label="تصویرسازی دانش‌آموز در حال مطالعه با لپ‌تاپ و کتاب">
<div className="relative aspect-[1.12/1] overflow-hidden rounded-[40px] border-[7px] border-white bg-[#dedaff] shadow-[0_30px_80px_rgba(56,48,118,.20)]"><div className="absolute inset-x-0 bottom-0 h-[31%] bg-[#f6c957]" /><div className="absolute left-[9%] top-[10%] grid size-16 -rotate-6 place-items-center rounded-2xl bg-white text-[#5b50d6] shadow-lg"><span className="text-xl font-black">x²</span></div><div className="absolute right-[8%] top-[12%] grid size-14 rotate-6 place-items-center rounded-full bg-emerald-400 text-white shadow-lg"><Lightbulb size={27} /></div><div className="absolute left-[17%] top-[23%] h-[45%] w-[67%]"><div className="absolute left-1/2 top-0 size-[29%] -translate-x-1/2 rounded-full bg-[#f2b38c]" /><div className="absolute left-[39%] top-[12%] h-[12%] w-[24%] rounded-t-full bg-slate-800" /><div className="absolute bottom-0 left-1/2 h-[69%] w-[64%] -translate-x-1/2 rounded-t-[45%] bg-[#5b50d6]" /><div className="absolute bottom-[8%] left-[5%] h-[17%] w-[42%] -rotate-[20deg] rounded-full bg-[#f2b38c]" /><div className="absolute bottom-[8%] right-[5%] h-[17%] w-[42%] rotate-[20deg] rounded-full bg-[#f2b38c]" /></div><div className="absolute bottom-[17%] left-1/2 h-[27%] w-[58%] -translate-x-1/2 rounded-xl border-[7px] border-slate-700 bg-white shadow-xl"><div className="grid h-full place-items-center text-[#5b50d6]"><Monitor size={38} strokeWidth={1.8} /></div></div><div className="absolute bottom-[14%] left-[17%] h-3 w-[66%] rounded-full bg-slate-700" /><div className="absolute bottom-[7%] right-[5%] flex -rotate-6"><span className="h-5 w-20 rounded-sm bg-emerald-400" /><span className="h-5 w-20 rounded-sm bg-white" /></div></div>
<div className="float-card absolute -right-3 top-1/2 grid size-16 -rotate-6 place-items-center rounded-2xl bg-white text-[#5b50d6] shadow-xl sm:-right-8"><PenLine size={29} strokeWidth={2.2} /></div><div className="float-card-delayed absolute -bottom-5 -left-2 flex items-center gap-3 rounded-2xl bg-white px-4 py-3 shadow-xl sm:-left-8"><span className="grid size-10 place-items-center rounded-xl bg-emerald-100 text-emerald-600"><Check size={20} strokeWidth={3} /></span><span><strong className="block text-sm font-black">یادگیری امروز</strong><small className="text-slate-400">یک قدم رو به جلو</small></span></div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,6 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
export default function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (page: number) => void }) {
if (totalPages <= 1) return null;
return <nav className="mt-10 flex items-center justify-center gap-2" aria-label="صفحه‌بندی"><button disabled={page === 1} onClick={() => onChange(page - 1)} className="grid size-10 place-items-center rounded-xl border border-slate-200 bg-white disabled:opacity-40"><ChevronRight size={18} /></button>{Array.from({ length: totalPages }, (_, index) => index + 1).map((item) => <button key={item} onClick={() => onChange(item)} className={`size-10 rounded-xl text-sm font-black ${page === item ? "bg-[#5b50d6] text-white" : "border border-slate-200 bg-white"}`}>{item.toLocaleString("fa-IR")}</button>)}<button disabled={page === totalPages} onClick={() => onChange(page + 1)} className="grid size-10 place-items-center rounded-xl border border-slate-200 bg-white disabled:opacity-40"><ChevronLeft size={18} /></button></nav>;
}

View File

@@ -0,0 +1,23 @@
import { useState } from "react";
import { BookOpen } from "lucide-react";
type SafeImageProps = {
src: string;
alt: string;
className: string;
eager?: boolean;
};
export default function SafeImage({ src, alt, className, eager = false }: SafeImageProps) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className={`grid place-items-center bg-gradient-to-br from-[#eeecff] to-[#fff3c9] text-[#5b50d6] ${className}`} role="img" aria-label={alt}>
<BookOpen size={26} strokeWidth={1.8} />
</div>
);
}
return <img src={src} alt={alt} className={className} loading={eager ? "eager" : "lazy"} onError={() => setFailed(true)} />;
}