feat: blog pages and wp data
This commit is contained in:
0
blog-debug.html
Normal file
0
blog-debug.html
Normal file
49
src/api/wp.dto.ts
Normal file
49
src/api/wp.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface WPRenderedField {
|
||||
rendered: string
|
||||
protected?: boolean
|
||||
}
|
||||
|
||||
export interface WPMedia {
|
||||
id: number
|
||||
source_url: string
|
||||
alt_text?: string
|
||||
}
|
||||
|
||||
export interface WPTerm {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
taxonomy: string
|
||||
}
|
||||
|
||||
export interface WPPost {
|
||||
id: number
|
||||
date: string
|
||||
modified: string
|
||||
slug: string
|
||||
link: string
|
||||
title: WPRenderedField
|
||||
content: WPRenderedField
|
||||
excerpt: WPRenderedField
|
||||
featured_media: number
|
||||
categories: number[]
|
||||
tags: number[]
|
||||
_embedded?: {
|
||||
"wp:featuredmedia"?: WPMedia[]
|
||||
"wp:term"?: WPTerm[][]
|
||||
}
|
||||
}
|
||||
|
||||
export interface WPCategory {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface WPPostsResponse {
|
||||
articles: import("@/lib/blog-types").BlogArticle[]
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
}
|
||||
249
src/api/wp.ts
Normal file
249
src/api/wp.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type {
|
||||
BlogArticle,
|
||||
BlogArticleDetail,
|
||||
BlogArticleSeo,
|
||||
BlogCategory,
|
||||
} from "@/lib/blog-types"
|
||||
import type { WPCategory, WPPost, WPPostsResponse } from "./wp.dto"
|
||||
|
||||
export const DOMAIN =
|
||||
import.meta.env.PUBLIC_WP_API_URL ?? "https://sixthsense-massage.com"
|
||||
export const API_URL = `${DOMAIN}/wp-json/wp/v2`
|
||||
export const POSTS_PER_PAGE = 9
|
||||
export const SITE_NAME = "نکستا"
|
||||
export const ENDPOINTS = {
|
||||
POSTS: `${API_URL}/posts`,
|
||||
CATEGORIES: `${API_URL}/categories`,
|
||||
} as const
|
||||
|
||||
export type FetchPostsOptions = {
|
||||
page?: number
|
||||
perPage?: number
|
||||
search?: string
|
||||
categoryIds?: number[]
|
||||
}
|
||||
|
||||
function stripHtml(html: string) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function decodeHtml(text: string) {
|
||||
return text
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||
.replace(/…/g, "…")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/’/g, "'")
|
||||
}
|
||||
|
||||
function formatPersianDate(isoDate: string) {
|
||||
return new Date(isoDate).toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function estimateReadTime(contentHtml: string) {
|
||||
const words = stripHtml(contentHtml).split(/\s+/).filter(Boolean).length
|
||||
return Math.max(1, Math.ceil(words / 200))
|
||||
}
|
||||
|
||||
function getFeaturedImageUrl(post: WPPost) {
|
||||
const embedded = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url
|
||||
if (embedded) return embedded
|
||||
|
||||
const fromContent = post.content.rendered.match(/src="([^"]+)"/)?.[1]
|
||||
return fromContent ?? null
|
||||
}
|
||||
|
||||
function getPostCategories(post: WPPost) {
|
||||
const terms = post._embedded?.["wp:term"]?.flat() ?? []
|
||||
const categories = terms
|
||||
.filter((term) => term.taxonomy === "category")
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
|
||||
if (categories.length > 0) return categories
|
||||
|
||||
return [{ id: 0, name: "بدون دسته" }]
|
||||
}
|
||||
|
||||
export function mapPostToArticle(post: WPPost): BlogArticle {
|
||||
const slug = decodeURIComponent(post.slug)
|
||||
|
||||
return {
|
||||
id: post.id,
|
||||
slug,
|
||||
href: `/blog/${encodeURI(slug)}`,
|
||||
title: decodeHtml(stripHtml(post.title.rendered)),
|
||||
excerpt: decodeHtml(stripHtml(post.excerpt.rendered)),
|
||||
categories: getPostCategories(post),
|
||||
date: formatPersianDate(post.date),
|
||||
dateIso: post.date,
|
||||
readTime: estimateReadTime(post.content.rendered),
|
||||
imageUrl: getFeaturedImageUrl(post),
|
||||
}
|
||||
}
|
||||
|
||||
export function getSiteOrigin(fallbackOrigin: string) {
|
||||
return (import.meta.env.PUBLIC_SITE_URL ?? fallbackOrigin).replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function buildSeoDescription(excerptHtml: string) {
|
||||
const text = decodeHtml(stripHtml(excerptHtml))
|
||||
.replace(/…$/, "")
|
||||
.trim()
|
||||
|
||||
if (text.length <= 160) return text
|
||||
return `${text.slice(0, 157).trim()}...`
|
||||
}
|
||||
|
||||
function buildArticleCanonical(slug: string, siteOrigin: string) {
|
||||
return `${siteOrigin}/blog/${encodeURI(slug)}`
|
||||
}
|
||||
|
||||
function buildArticleSeo(post: WPPost, siteOrigin: string): BlogArticleSeo {
|
||||
const slug = decodeURIComponent(post.slug)
|
||||
const title = decodeHtml(stripHtml(post.title.rendered))
|
||||
|
||||
return {
|
||||
title,
|
||||
description: buildSeoDescription(post.excerpt.rendered),
|
||||
canonical: buildArticleCanonical(slug, siteOrigin),
|
||||
ogImage: getFeaturedImageUrl(post),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPostToDetail(
|
||||
post: WPPost,
|
||||
siteOrigin: string,
|
||||
): BlogArticleDetail {
|
||||
const article = mapPostToArticle(post)
|
||||
|
||||
return {
|
||||
...article,
|
||||
contentHtml: post.content.rendered,
|
||||
modifiedIso: post.modified ?? post.date,
|
||||
seo: buildArticleSeo(post, siteOrigin),
|
||||
}
|
||||
}
|
||||
export function buildPostsUrl({
|
||||
page = 1,
|
||||
perPage = POSTS_PER_PAGE,
|
||||
search,
|
||||
categoryIds,
|
||||
}: FetchPostsOptions) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
_embed: "wp:featuredmedia,wp:term",
|
||||
})
|
||||
|
||||
if (search?.trim()) params.set("search", search.trim())
|
||||
categoryIds?.forEach((id) => params.append("categories", String(id)))
|
||||
|
||||
return `${ENDPOINTS.POSTS}?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function fetchPosts(
|
||||
options: FetchPostsOptions = {},
|
||||
): Promise<WPPostsResponse> {
|
||||
const page = options.page ?? 1
|
||||
const perPage = options.perPage ?? POSTS_PER_PAGE
|
||||
|
||||
const response = await fetch(buildPostsUrl({ ...options, page, perPage }))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.status}`)
|
||||
}
|
||||
|
||||
const posts = (await response.json()) as WPPost[]
|
||||
const total = Number(response.headers.get("X-WP-Total") ?? posts.length)
|
||||
const totalPages = Number(response.headers.get("X-WP-TotalPages") ?? 1)
|
||||
|
||||
return {
|
||||
articles: posts.map(mapPostToArticle),
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPostBySlug(slug: string): Promise<WPPost | null> {
|
||||
const decoded = decodeURIComponent(slug)
|
||||
const slugCandidates = [...new Set([decoded, slug, encodeURIComponent(decoded)])]
|
||||
|
||||
for (const candidate of slugCandidates) {
|
||||
const params = new URLSearchParams({
|
||||
slug: candidate,
|
||||
_embed: "wp:featuredmedia,wp:term",
|
||||
})
|
||||
|
||||
const response = await fetch(`${ENDPOINTS.POSTS}?${params.toString()}`)
|
||||
|
||||
if (!response.ok) continue
|
||||
|
||||
const posts = (await response.json()) as WPPost[]
|
||||
if (posts[0]) return posts[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function fetchAllPosts(): Promise<WPPost[]> {
|
||||
const posts: WPPost[] = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (page <= totalPages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: "100",
|
||||
_embed: "wp:featuredmedia,wp:term",
|
||||
})
|
||||
|
||||
const response = await fetch(`${ENDPOINTS.POSTS}?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.status}`)
|
||||
}
|
||||
|
||||
const batch = (await response.json()) as WPPost[]
|
||||
posts.push(...batch)
|
||||
|
||||
totalPages = Number(response.headers.get("X-WP-TotalPages") ?? 1)
|
||||
page += 1
|
||||
}
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
export async function fetchAllPostSlugs(): Promise<string[]> {
|
||||
const posts = await fetchAllPosts()
|
||||
return posts.map((post) => decodeURIComponent(post.slug))
|
||||
}
|
||||
|
||||
export async function fetchCategories(): Promise<BlogCategory[]> {
|
||||
const params = new URLSearchParams({
|
||||
per_page: "100",
|
||||
hide_empty: "true",
|
||||
orderby: "count",
|
||||
order: "desc",
|
||||
})
|
||||
|
||||
const response = await fetch(`${ENDPOINTS.CATEGORIES}?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch categories: ${response.status}`)
|
||||
}
|
||||
|
||||
const categories = (await response.json()) as WPCategory[]
|
||||
|
||||
return categories.map(({ id, name, slug }) => ({ id, name, slug }))
|
||||
}
|
||||
83
src/components/blog/ArticleCard.astro
Normal file
83
src/components/blog/ArticleCard.astro
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
import LucideIcon from "@/components/ui/lucide-icon.astro";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BlogArticle } from "@/lib/blog-types";
|
||||
|
||||
interface Props {
|
||||
article: BlogArticle;
|
||||
}
|
||||
|
||||
const { article } = Astro.props;
|
||||
const categoryIds = article.categories.map((category) => category.id).join(",");
|
||||
---
|
||||
|
||||
<article
|
||||
class="blog-card group flex flex-col overflow-hidden rounded-2xl border border-transparent bg-card transition-colors hover:border-secondary/60"
|
||||
data-categories={categoryIds}
|
||||
data-title={article.title}
|
||||
data-excerpt={article.excerpt}
|
||||
>
|
||||
<div class="relative aspect-[16/10] overflow-hidden">
|
||||
{
|
||||
article.imageUrl ? (
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div class="h-full w-full bg-muted" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-3 p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-wrap gap-1.5">
|
||||
{
|
||||
article.categories.map((category) => (
|
||||
<span class="rounded-full bg-background/70 px-3 py-1 text-xs font-medium text-primary backdrop-blur-sm">
|
||||
{category.name}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<time
|
||||
class="shrink-0 text-xs text-muted-foreground"
|
||||
datetime={article.dateIso}
|
||||
>
|
||||
{article.date}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h2 class="text-base font-bold leading-relaxed text-foreground">
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground"
|
||||
>
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LucideIcon name="clock" class="size-3.5" />
|
||||
{article.readTime} دقیقه مطالعه
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={article.href}
|
||||
class={cn(
|
||||
buttonVariants({ size: "sm" }),
|
||||
"rounded-full px-4 py-1.5 text-xs",
|
||||
)}
|
||||
>
|
||||
مطالعه مقاله
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
54
src/components/blog/ArticleDetail.astro
Normal file
54
src/components/blog/ArticleDetail.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import LucideIcon from "@/components/ui/lucide-icon.astro";
|
||||
import type { BlogArticleDetail } from "@/lib/blog-types";
|
||||
|
||||
interface Props {
|
||||
article: BlogArticleDetail;
|
||||
}
|
||||
|
||||
const { article } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="mx-auto max-w-3xl">
|
||||
<header class="space-y-5 text-center">
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
{
|
||||
article.categories.map((category) => (
|
||||
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{category.name}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold leading-tight text-foreground md:text-4xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<time datetime={article.dateIso}>{article.date}</time>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<LucideIcon name="clock" class="size-4" />
|
||||
{article.readTime} دقیقه مطالعه
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{
|
||||
article.imageUrl && (
|
||||
<div class="mt-8 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt={article.title}
|
||||
class="aspect-[16/10] w-full object-cover"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wp-content mt-10" set:html={article.contentHtml} />
|
||||
</article>
|
||||
56
src/components/blog/CategoryFilter.astro
Normal file
56
src/components/blog/CategoryFilter.astro
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import LucideIcon from "@/components/ui/lucide-icon.astro";
|
||||
import type { BlogCategory } from "@/lib/blog-types";
|
||||
|
||||
interface Props {
|
||||
categories: BlogCategory[];
|
||||
fieldClass: string;
|
||||
selectedIds?: number[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const { categories, fieldClass, selectedIds = [], label = "دستهبندیها" } =
|
||||
Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative shrink-0 sm:w-52" id="blog-category-filter">
|
||||
<button
|
||||
type="button"
|
||||
id="blog-category-trigger"
|
||||
class:list={[
|
||||
fieldClass,
|
||||
"flex w-full px-4 cursor-pointer items-center justify-between gap-2 text-start",
|
||||
]}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span id="blog-category-label" class="truncate">{label}</span>
|
||||
<LucideIcon name="chevronDown" class="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="blog-category-panel"
|
||||
class="absolute top-full px-10 z-20 mt-2 hidden max-h-60 w-full overflow-y-auto rounded-2xl border border-border bg-card p-2 shadow-lg"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
{
|
||||
categories.length === 0 ? (
|
||||
<p class="px-3 py-2 text-sm text-muted-foreground">دستهای یافت نشد</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 shrink-0 accent-primary"
|
||||
value={category.id}
|
||||
data-category-checkbox
|
||||
checked={selectedIds.includes(category.id)}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{category.name}</span>
|
||||
</label>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
35
src/components/ui/lucide-icon.astro
Normal file
35
src/components/ui/lucide-icon.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
name: "search" | "chevronDown" | "clock";
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, class: className } = Astro.props;
|
||||
|
||||
const icons = {
|
||||
search: [
|
||||
'<circle cx="11" cy="11" r="8"/>',
|
||||
'<path d="m21 21-4.34-4.34"/>',
|
||||
],
|
||||
chevronDown: ['<path d="m6 9 6 6 6-6"/>'],
|
||||
clock: [
|
||||
'<circle cx="12" cy="12" r="10"/>',
|
||||
'<path d="M12 6v6l4 2"/>',
|
||||
],
|
||||
} as const;
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn("size-4 shrink-0", className)}
|
||||
aria-hidden="true"
|
||||
set:html={icons[name].join("")}
|
||||
/>
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Building2,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Cloud,
|
||||
Dumbbell,
|
||||
FileText,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Search,
|
||||
SportShoe,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
@@ -51,6 +54,9 @@ export const landingIcons = {
|
||||
star: Star,
|
||||
check: Check,
|
||||
calendar: Calendar,
|
||||
chevronDown: ChevronDown,
|
||||
clock: Clock,
|
||||
search: Search,
|
||||
x: X,
|
||||
} as const satisfies Record<string, LucideIconComponent>
|
||||
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
import "../styles/global.css";
|
||||
import Header from "@/components/views/Header.astro";
|
||||
import Footer from "@/components/views/Footer.astro";
|
||||
import { SITE_NAME } from "@/api/wp";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
canonical?: string;
|
||||
ogImage?: string | null;
|
||||
ogType?: string;
|
||||
jsonLd?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const { title = "نکستا" } = Astro.props;
|
||||
const {
|
||||
title = SITE_NAME,
|
||||
description,
|
||||
canonical,
|
||||
ogImage,
|
||||
ogType = "website",
|
||||
jsonLd,
|
||||
} = Astro.props;
|
||||
|
||||
const pageTitle = title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -19,7 +34,27 @@ const { title = "نکستا" } = Astro.props;
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/font/font.css" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
<title>{pageTitle}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
{canonical && <link rel="canonical" href={canonical} />}
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
{canonical && <meta property="og:url" content={canonical} />}
|
||||
<meta property="og:type" content={ogType} />
|
||||
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{description && <meta name="twitter:description" content={description} />}
|
||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||
{
|
||||
jsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(jsonLd)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
|
||||
345
src/lib/blog-client.ts
Normal file
345
src/lib/blog-client.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
POSTS_PER_PAGE,
|
||||
buildPostsUrl,
|
||||
mapPostToArticle,
|
||||
type FetchPostsOptions,
|
||||
} from "@/api/wp"
|
||||
import {
|
||||
buildBlogUrl,
|
||||
filtersAreEqual,
|
||||
parseBlogSearchParams,
|
||||
readSsrFilters,
|
||||
type BlogFilters,
|
||||
} from "@/lib/blog-params"
|
||||
import type { BlogArticle } from "@/lib/blog-types"
|
||||
import type { WPPost } from "@/api/wp.dto"
|
||||
|
||||
export { POSTS_PER_PAGE, mapPostToArticle }
|
||||
|
||||
export type ClientPostsResult = {
|
||||
articles: BlogArticle[]
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 400
|
||||
|
||||
export async function fetchPostsClient(
|
||||
options: FetchPostsOptions,
|
||||
): Promise<ClientPostsResult> {
|
||||
const response = await fetch(buildPostsUrl(options))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.status}`)
|
||||
}
|
||||
|
||||
const posts = (await response.json()) as WPPost[]
|
||||
|
||||
return {
|
||||
articles: posts.map(mapPostToArticle),
|
||||
page: options.page ?? 1,
|
||||
totalPages: Number(response.headers.get("X-WP-TotalPages") ?? 1),
|
||||
}
|
||||
}
|
||||
|
||||
const clockIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 shrink-0" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>`
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
function renderCategoryBadges(categories: BlogArticle["categories"]) {
|
||||
return categories
|
||||
.map(
|
||||
(category) =>
|
||||
`<span class="rounded-full bg-background/70 px-3 py-1 text-xs font-medium text-primary backdrop-blur-sm">${escapeHtml(category.name)}</span>`,
|
||||
)
|
||||
.join("")
|
||||
}
|
||||
|
||||
export function renderArticleCard(article: BlogArticle) {
|
||||
const image = article.imageUrl
|
||||
? `<img src="${escapeHtml(article.imageUrl)}" alt="" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" decoding="async" />`
|
||||
: `<div class="h-full w-full bg-muted" aria-hidden="true"></div>`
|
||||
|
||||
const categoryIds = article.categories.map((category) => category.id).join(",")
|
||||
|
||||
return `
|
||||
<article
|
||||
class="blog-card group flex flex-col overflow-hidden rounded-2xl border border-transparent bg-card transition-colors hover:border-secondary/60"
|
||||
data-categories="${categoryIds}"
|
||||
data-title="${escapeHtml(article.title)}"
|
||||
data-excerpt="${escapeHtml(article.excerpt)}"
|
||||
>
|
||||
<div class="relative aspect-[16/10] overflow-hidden">${image}</div>
|
||||
<div class="flex flex-1 flex-col gap-3 p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-wrap gap-1.5">${renderCategoryBadges(article.categories)}</div>
|
||||
<time class="shrink-0 text-xs text-muted-foreground" datetime="${escapeHtml(article.dateIso)}">${escapeHtml(article.date)}</time>
|
||||
</div>
|
||||
<h2 class="text-base font-bold leading-relaxed text-foreground">${escapeHtml(article.title)}</h2>
|
||||
<p class="line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground">${escapeHtml(article.excerpt)}</p>
|
||||
<div class="mt-2 flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">${clockIcon}${article.readTime} دقیقه مطالعه</span>
|
||||
<a href="${escapeHtml(article.href)}" class="group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-all hover:bg-primary/80">مطالعه مقاله</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`.trim()
|
||||
}
|
||||
|
||||
function getSelectedCategoryIds() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>("[data-category-checkbox]:checked"),
|
||||
)
|
||||
.map((input) => Number(input.value))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function readFiltersFromForm(): BlogFilters {
|
||||
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
|
||||
|
||||
return {
|
||||
q: searchInput?.value ?? "",
|
||||
categoryIds: getSelectedCategoryIds(),
|
||||
}
|
||||
}
|
||||
|
||||
function readFiltersFromUrl() {
|
||||
return parseBlogSearchParams(new URLSearchParams(window.location.search))
|
||||
}
|
||||
|
||||
function syncUrl(filters: BlogFilters) {
|
||||
const nextUrl = buildBlogUrl(filters, window.location.pathname)
|
||||
history.replaceState(null, "", nextUrl)
|
||||
}
|
||||
|
||||
function updateCategoryLabel() {
|
||||
const label = document.getElementById("blog-category-label")
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"[data-category-checkbox]",
|
||||
)
|
||||
|
||||
if (!label) return
|
||||
|
||||
const selectedNames = Array.from(checkboxes)
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.parentElement?.querySelector("span")?.textContent?.trim())
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
const selectedCount = selectedNames.length
|
||||
|
||||
if (selectedCount === 0) {
|
||||
label.textContent = "دستهبندیها"
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCount === 1) {
|
||||
label.textContent = selectedNames[0] ?? "۱ دسته"
|
||||
return
|
||||
}
|
||||
|
||||
label.textContent = `${selectedCount} دسته انتخاب شده`
|
||||
}
|
||||
|
||||
function applyFiltersToForm(filters: BlogFilters) {
|
||||
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"[data-category-checkbox]",
|
||||
)
|
||||
|
||||
if (searchInput) searchInput.value = filters.q
|
||||
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.checked = filters.categoryIds.includes(Number(checkbox.value))
|
||||
})
|
||||
|
||||
updateCategoryLabel()
|
||||
}
|
||||
|
||||
function setGridLoading(loading: boolean) {
|
||||
const loadingEl = document.getElementById("blog-loading")
|
||||
const grid = document.getElementById("blog-grid")
|
||||
const loadMoreButton = document.getElementById(
|
||||
"blog-load-more",
|
||||
) as HTMLButtonElement | null
|
||||
|
||||
loadingEl?.classList.toggle("hidden", !loading)
|
||||
loadingEl?.classList.toggle("flex", loading)
|
||||
grid?.classList.toggle("pointer-events-none", loading)
|
||||
grid?.classList.toggle("opacity-50", loading)
|
||||
|
||||
if (loadMoreButton) loadMoreButton.disabled = loading
|
||||
}
|
||||
|
||||
export function initBlogPage() {
|
||||
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
|
||||
const grid = document.getElementById("blog-grid") as HTMLElement | null
|
||||
const emptyState = document.getElementById("blog-empty")
|
||||
const loadMoreWrap = document.getElementById("blog-load-more-wrap")
|
||||
const loadMoreButton = document.getElementById(
|
||||
"blog-load-more",
|
||||
) as HTMLButtonElement | null
|
||||
const filter = document.getElementById("blog-category-filter")
|
||||
const trigger = document.getElementById("blog-category-trigger")
|
||||
const panel = document.getElementById("blog-category-panel")
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"[data-category-checkbox]",
|
||||
)
|
||||
|
||||
if (!grid) return
|
||||
|
||||
const gridEl = grid
|
||||
|
||||
let currentPage = Number(gridEl.dataset.page ?? 1)
|
||||
let totalPages = Number(gridEl.dataset.totalPages ?? 1)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let isLoading = false
|
||||
let activeFilters: BlogFilters = { q: "", categoryIds: [] }
|
||||
|
||||
function bootstrapFromUrl() {
|
||||
const urlFilters = readFiltersFromUrl()
|
||||
const ssrFilters = readSsrFilters(gridEl)
|
||||
|
||||
applyFiltersToForm(urlFilters)
|
||||
activeFilters = urlFilters
|
||||
|
||||
if (!filtersAreEqual(urlFilters, ssrFilters)) {
|
||||
void loadPosts({ reset: true, filters: urlFilters, updateUrl: false })
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadMoreVisibility() {
|
||||
loadMoreWrap?.classList.toggle("hidden", currentPage >= totalPages)
|
||||
}
|
||||
|
||||
async function loadPosts({
|
||||
reset,
|
||||
filters,
|
||||
updateUrl = false,
|
||||
}: {
|
||||
reset: boolean
|
||||
filters: BlogFilters
|
||||
updateUrl?: boolean
|
||||
}) {
|
||||
if (isLoading) return
|
||||
|
||||
const nextPage = reset ? 1 : currentPage + 1
|
||||
if (!reset && nextPage > totalPages) return
|
||||
|
||||
if (updateUrl) syncUrl(filters)
|
||||
|
||||
isLoading = true
|
||||
setGridLoading(true)
|
||||
|
||||
try {
|
||||
const result = await fetchPostsClient({
|
||||
page: nextPage,
|
||||
perPage: POSTS_PER_PAGE,
|
||||
search: filters.q.trim() || undefined,
|
||||
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
|
||||
})
|
||||
|
||||
if (reset) {
|
||||
gridEl.innerHTML = ""
|
||||
currentPage = 1
|
||||
} else {
|
||||
currentPage = nextPage
|
||||
}
|
||||
|
||||
activeFilters = filters
|
||||
totalPages = result.totalPages
|
||||
gridEl.dataset.page = String(currentPage)
|
||||
gridEl.dataset.totalPages = String(totalPages)
|
||||
|
||||
if (result.articles.length === 0 && reset) {
|
||||
emptyState?.classList.remove("hidden")
|
||||
if (emptyState) emptyState.textContent = "مقالهای پیدا نشد."
|
||||
} else {
|
||||
emptyState?.classList.add("hidden")
|
||||
gridEl.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
result.articles.map(renderArticleCard).join(""),
|
||||
)
|
||||
}
|
||||
|
||||
updateLoadMoreVisibility()
|
||||
} catch {
|
||||
if (reset) gridEl.innerHTML = ""
|
||||
emptyState?.classList.remove("hidden")
|
||||
if (emptyState) emptyState.textContent = "خطا در دریافت مقالات."
|
||||
loadMoreWrap?.classList.add("hidden")
|
||||
} finally {
|
||||
isLoading = false
|
||||
setGridLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function requestFilterChange(updateUrl: boolean) {
|
||||
const filters = readFiltersFromForm()
|
||||
|
||||
if (filtersAreEqual(filters, activeFilters) && updateUrl) return
|
||||
|
||||
void loadPosts({ reset: true, filters, updateUrl })
|
||||
}
|
||||
|
||||
if (trigger && panel && filter) {
|
||||
const triggerButton = trigger
|
||||
const panelElement = panel
|
||||
|
||||
function closePanel() {
|
||||
panelElement.classList.add("hidden")
|
||||
triggerButton.setAttribute("aria-expanded", "false")
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelElement.classList.remove("hidden")
|
||||
triggerButton.setAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
triggerButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation()
|
||||
const isOpen = triggerButton.getAttribute("aria-expanded") === "true"
|
||||
if (isOpen) closePanel()
|
||||
else openPanel()
|
||||
})
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!filter.contains(event.target as Node)) closePanel()
|
||||
})
|
||||
}
|
||||
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
updateCategoryLabel()
|
||||
requestFilterChange(true)
|
||||
})
|
||||
})
|
||||
|
||||
searchInput?.addEventListener("input", () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
requestFilterChange(true)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
loadMoreButton?.addEventListener("click", () => {
|
||||
void loadPosts({ reset: false, filters: activeFilters })
|
||||
})
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
const filters = readFiltersFromUrl()
|
||||
applyFiltersToForm(filters)
|
||||
|
||||
if (filtersAreEqual(filters, activeFilters)) return
|
||||
|
||||
void loadPosts({ reset: true, filters, updateUrl: false })
|
||||
})
|
||||
|
||||
bootstrapFromUrl()
|
||||
updateLoadMoreVisibility()
|
||||
}
|
||||
52
src/lib/blog-params.ts
Normal file
52
src/lib/blog-params.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type BlogFilters = {
|
||||
q: string
|
||||
categoryIds: number[]
|
||||
}
|
||||
|
||||
export function parseBlogSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
): BlogFilters {
|
||||
const q = searchParams.get("q") ?? ""
|
||||
const categoryIds = parseCategoryIds(searchParams.get("categories") ?? undefined)
|
||||
|
||||
return { q, categoryIds }
|
||||
}
|
||||
|
||||
export function buildBlogQueryString({ q, categoryIds }: BlogFilters) {
|
||||
const params = new URLSearchParams()
|
||||
const trimmed = q.trim()
|
||||
|
||||
if (trimmed) params.set("q", trimmed)
|
||||
if (categoryIds.length > 0) params.set("categories", categoryIds.join(","))
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function buildBlogUrl(filters: BlogFilters, pathname = "/blog") {
|
||||
const query = buildBlogQueryString(filters)
|
||||
return query ? `${pathname}?${query}` : pathname
|
||||
}
|
||||
|
||||
export function filtersAreEqual(a: BlogFilters, b: BlogFilters) {
|
||||
if (a.q.trim() !== b.q.trim()) return false
|
||||
if (a.categoryIds.length !== b.categoryIds.length) return false
|
||||
|
||||
const sortedA = [...a.categoryIds].sort((x, y) => x - y)
|
||||
const sortedB = [...b.categoryIds].sort((x, y) => x - y)
|
||||
|
||||
return sortedA.every((id, index) => id === sortedB[index])
|
||||
}
|
||||
|
||||
export function parseCategoryIds(value: string | undefined) {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter((id) => id > 0)
|
||||
}
|
||||
|
||||
export function readSsrFilters(element: HTMLElement): BlogFilters {
|
||||
return {
|
||||
q: element.dataset.ssrQ ?? "",
|
||||
categoryIds: parseCategoryIds(element.dataset.ssrCategories),
|
||||
}
|
||||
}
|
||||
36
src/lib/blog-types.ts
Normal file
36
src/lib/blog-types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type BlogCategoryItem = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BlogArticle = {
|
||||
id: number
|
||||
slug: string
|
||||
href: string
|
||||
title: string
|
||||
excerpt: string
|
||||
categories: BlogCategoryItem[]
|
||||
date: string
|
||||
dateIso: string
|
||||
readTime: number
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
export type BlogCategory = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type BlogArticleSeo = {
|
||||
title: string
|
||||
description: string
|
||||
canonical: string
|
||||
ogImage: string | null
|
||||
}
|
||||
|
||||
export type BlogArticleDetail = BlogArticle & {
|
||||
contentHtml: string
|
||||
modifiedIso: string
|
||||
seo: BlogArticleSeo
|
||||
}
|
||||
@@ -1,9 +1,173 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import ArticleCard from "@/components/blog/ArticleCard.astro";
|
||||
import CategoryFilter from "@/components/blog/CategoryFilter.astro";
|
||||
import LucideIcon from "@/components/ui/lucide-icon.astro";
|
||||
import { fetchCategories, fetchPosts, POSTS_PER_PAGE } from "@/api/wp";
|
||||
import { parseBlogSearchParams } from "@/lib/blog-params";
|
||||
|
||||
const fieldClass =
|
||||
"h-11 w-full rounded-full border border-border bg-card text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20";
|
||||
|
||||
const initialFilters = parseBlogSearchParams(Astro.url.searchParams);
|
||||
|
||||
let articles: Awaited<ReturnType<typeof fetchPosts>>["articles"] = [];
|
||||
let categories: Awaited<ReturnType<typeof fetchCategories>> = [];
|
||||
let totalPages = 1;
|
||||
let fetchError: string | null = null;
|
||||
|
||||
try {
|
||||
const [postsResult, categoriesResult] = await Promise.all([
|
||||
fetchPosts({
|
||||
page: 1,
|
||||
perPage: POSTS_PER_PAGE,
|
||||
search: initialFilters.q || undefined,
|
||||
categoryIds: initialFilters.categoryIds.length
|
||||
? initialFilters.categoryIds
|
||||
: undefined,
|
||||
}),
|
||||
fetchCategories(),
|
||||
]);
|
||||
|
||||
articles = postsResult.articles;
|
||||
totalPages = postsResult.totalPages;
|
||||
categories = categoriesResult;
|
||||
} catch (error) {
|
||||
fetchError =
|
||||
error instanceof Error ? error.message : "خطا در دریافت مقالات";
|
||||
}
|
||||
|
||||
function getCategoryFilterLabel(
|
||||
categoryIds: number[],
|
||||
allCategories: typeof categories,
|
||||
) {
|
||||
if (categoryIds.length === 0) return "دستهبندیها";
|
||||
if (categoryIds.length === 1) {
|
||||
return (
|
||||
allCategories.find((category) => category.id === categoryIds[0])?.name ??
|
||||
"۱ دسته"
|
||||
);
|
||||
}
|
||||
return `${categoryIds.length} دسته انتخاب شده`;
|
||||
}
|
||||
|
||||
const categoryFilterLabel = getCategoryFilterLabel(
|
||||
initialFilters.categoryIds,
|
||||
categories,
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title="بلاگ | نکستا">
|
||||
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">بلاگ</h1>
|
||||
<section
|
||||
class="relative overflow-hidden bg-background px-4 pb-16 pt-12 md:px-6 md:pb-20 md:pt-16 lg:px-8"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(165,255,1,0.06),transparent_55%)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative mx-auto max-w-7xl">
|
||||
<header class="mx-auto max-w-3xl text-center">
|
||||
<h1 class="text-3xl font-bold text-foreground md:text-4xl">
|
||||
دانش و تجربهای برای
|
||||
<span class="text-primary">رشد باشگاه شما</span>
|
||||
</h1>
|
||||
<p
|
||||
class="mt-4 text-sm leading-relaxed text-muted-foreground md:text-base"
|
||||
>
|
||||
از نکات مدیریتی تا ترندهای روز صنعت فیتنس؛ هرآنچه برای توسعه باشگاه و
|
||||
افزایش رضایت اعضا نیاز دارید.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="relative mx-auto mt-10 flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:gap-4 md:mt-12"
|
||||
>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
id="blog-search"
|
||||
type="search"
|
||||
placeholder="جستجو کنید"
|
||||
value={initialFilters.q}
|
||||
class:list={[fieldClass, "pe-4 ps-11"]}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<LucideIcon
|
||||
name="search"
|
||||
class="pointer-events-none absolute start-4 top-1/2 z-10 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryFilter
|
||||
categories={categories}
|
||||
fieldClass={fieldClass}
|
||||
selectedIds={initialFilters.categoryIds}
|
||||
label={categoryFilterLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
fetchError && (
|
||||
<p class="mt-10 text-center text-sm text-destructive">{fetchError}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="relative mt-10 md:mt-12">
|
||||
<div
|
||||
id="blog-loading"
|
||||
class="absolute inset-0 z-10 hidden items-center justify-center rounded-2xl bg-background/70 backdrop-blur-sm"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span
|
||||
class="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent"
|
||||
aria-hidden="true"></span>
|
||||
در حال دریافت مقالات...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="blog-grid"
|
||||
class="grid grid-cols-1 gap-6 transition-opacity sm:grid-cols-2 lg:grid-cols-3"
|
||||
data-page="1"
|
||||
data-total-pages={totalPages}
|
||||
data-ssr-q={initialFilters.q}
|
||||
data-ssr-categories={initialFilters.categoryIds.join(",")}
|
||||
>
|
||||
{articles.map((article) => <ArticleCard article={article} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="blog-empty"
|
||||
class:list={[
|
||||
"mt-10 text-center text-sm text-muted-foreground",
|
||||
articles.length > 0 && "hidden",
|
||||
]}
|
||||
>
|
||||
{fetchError ? "مقالهای برای نمایش وجود ندارد." : "مقالهای پیدا نشد."}
|
||||
</p>
|
||||
|
||||
<div
|
||||
id="blog-load-more-wrap"
|
||||
class:list={["mt-10 flex justify-center", totalPages <= 1 && "hidden"]}
|
||||
>
|
||||
<button
|
||||
id="blog-load-more"
|
||||
type="button"
|
||||
class="rounded-full border border-primary bg-transparent px-8 py-2.5 text-sm font-medium text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
مشاهده بیشتر
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { initBlogPage } from "@/lib/blog-client";
|
||||
|
||||
initBlogPage();
|
||||
</script>
|
||||
|
||||
101
src/pages/blog/[slug].astro
Normal file
101
src/pages/blog/[slug].astro
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import ArticleDetail from "@/components/blog/ArticleDetail.astro";
|
||||
import {
|
||||
fetchAllPosts,
|
||||
fetchPostBySlug,
|
||||
getSiteOrigin,
|
||||
mapPostToDetail,
|
||||
SITE_NAME,
|
||||
} from "@/api/wp";
|
||||
import type { WPPost } from "@/api/wp.dto";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const posts = await fetchAllPosts();
|
||||
return posts.map((post) => {
|
||||
const slug = decodeURIComponent(post.slug);
|
||||
return {
|
||||
params: { slug },
|
||||
props: { post },
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
post?: WPPost;
|
||||
}
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { post: staticPost } = Astro.props as Props;
|
||||
|
||||
if (!slug) {
|
||||
return Astro.redirect("/blog");
|
||||
}
|
||||
|
||||
const siteOrigin = getSiteOrigin(Astro.url.origin);
|
||||
const post = staticPost ?? (await fetchPostBySlug(slug));
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/blog");
|
||||
}
|
||||
|
||||
const article = mapPostToDetail(post, siteOrigin);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: article.seo.title,
|
||||
description: article.seo.description,
|
||||
image: article.seo.ogImage ? [article.seo.ogImage] : undefined,
|
||||
datePublished: article.dateIso,
|
||||
dateModified: article.modifiedIso,
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: SITE_NAME,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: SITE_NAME,
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
"@type": "WebPage",
|
||||
"@id": article.seo.canonical,
|
||||
},
|
||||
};
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={article.seo.title}
|
||||
description={article.seo.description}
|
||||
canonical={article.seo.canonical}
|
||||
ogImage={article.seo.ogImage}
|
||||
ogType="article"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<section
|
||||
class="relative overflow-hidden bg-background px-4 pb-16 pt-12 md:px-6 md:pb-20 md:pt-16 lg:px-8"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(165,255,1,0.06),transparent_55%)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative mx-auto max-w-7xl">
|
||||
<nav class="mb-8">
|
||||
<a
|
||||
href="/blog"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
← بازگشت به بلاگ
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<ArticleDetail article={article} />
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
@@ -175,6 +175,47 @@
|
||||
.contact-icon {
|
||||
@apply flex shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary;
|
||||
}
|
||||
|
||||
.wp-content {
|
||||
@apply space-y-5 text-base leading-relaxed text-muted-foreground;
|
||||
}
|
||||
|
||||
.wp-content h2 {
|
||||
@apply pt-2 text-2xl font-bold text-foreground;
|
||||
}
|
||||
|
||||
.wp-content h3 {
|
||||
@apply pt-1 text-xl font-semibold text-foreground;
|
||||
}
|
||||
|
||||
.wp-content p {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
.wp-content a {
|
||||
@apply text-primary underline-offset-4 transition-colors hover:text-primary/80 hover:underline;
|
||||
}
|
||||
|
||||
.wp-content ul,
|
||||
.wp-content ol {
|
||||
@apply space-y-2 ps-6;
|
||||
}
|
||||
|
||||
.wp-content ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.wp-content ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.wp-content img {
|
||||
@apply mx-auto w-full rounded-2xl;
|
||||
}
|
||||
|
||||
.wp-content img.aligncenter {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
Reference in New Issue
Block a user