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,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Clock,
|
||||||
Cloud,
|
Cloud,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
|
Search,
|
||||||
SportShoe,
|
SportShoe,
|
||||||
Star,
|
Star,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
@@ -51,6 +54,9 @@ export const landingIcons = {
|
|||||||
star: Star,
|
star: Star,
|
||||||
check: Check,
|
check: Check,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
|
chevronDown: ChevronDown,
|
||||||
|
clock: Clock,
|
||||||
|
search: Search,
|
||||||
x: X,
|
x: X,
|
||||||
} as const satisfies Record<string, LucideIconComponent>
|
} as const satisfies Record<string, LucideIconComponent>
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,65 @@
|
|||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import Header from "@/components/views/Header.astro";
|
import Header from "@/components/views/Header.astro";
|
||||||
import Footer from "@/components/views/Footer.astro";
|
import Footer from "@/components/views/Footer.astro";
|
||||||
|
import { SITE_NAME } from "@/api/wp";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
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>
|
<!doctype html>
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/font/font.css" />
|
<link rel="stylesheet" href="/font/font.css" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{pageTitle}</title>
|
||||||
</head>
|
{description && <meta name="description" content={description} />}
|
||||||
<body>
|
{canonical && <link rel="canonical" href={canonical} />}
|
||||||
<Header />
|
<meta property="og:locale" content="fa_IR" />
|
||||||
<main>
|
<meta property="og:site_name" content={SITE_NAME} />
|
||||||
<slot />
|
<meta property="og:title" content={pageTitle} />
|
||||||
</main>
|
{description && <meta property="og:description" content={description} />}
|
||||||
<Footer />
|
{canonical && <meta property="og:url" content={canonical} />}
|
||||||
</body>
|
<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 />
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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="بلاگ | نکستا">
|
<Layout title="بلاگ | نکستا">
|
||||||
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
<section
|
||||||
<h1 class="text-3xl font-bold text-foreground">بلاگ</h1>
|
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>
|
</section>
|
||||||
</Layout>
|
</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 {
|
.contact-icon {
|
||||||
@apply flex shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary;
|
@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 {
|
@layer utilities {
|
||||||
|
|||||||
Reference in New Issue
Block a user