Files
website-enchun-mgr/apps/frontend/src/pages/xing-xiao-fang-da-jing/[slug].astro
pkupuk 9c2181f743 feat(frontend): update pages, components and branding
Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
2026-02-11 11:50:42 +08:00

357 lines
8.1 KiB
Plaintext

---
/**
* Blog Article Detail Page - 行銷放大鏡文章詳情
* URL: /xing-xiao-fang-da-jing/[slug]
* Fetches from Payload CMS and renders with Lexical content
*/
import Layout from '../../layouts/Layout.astro'
import RelatedPosts from '../../components/blog/RelatedPosts.astro'
import { fetchPostBySlug, fetchRelatedPosts, formatDate } from '../../lib/api/blog'
import { serializeLexical } from '../../lib/serializeLexical'
// Get slug from params
const { slug } = Astro.params
// Validate slug
if (!slug) {
return Astro.redirect('/404')
}
// Fetch post by slug
const post = await fetchPostBySlug(slug)
// Handle 404 for non-existent or unpublished posts
if (!post) {
return Astro.redirect('/404')
}
// Fetch related posts
const categorySlugs = post.categories?.map(c => c.slug) || []
const relatedPosts = await fetchRelatedPosts(post.id, categorySlugs, 4)
// Format date
const displayDate = formatDate(post.publishedAt)
// Get primary category
const category = post.categories?.[0]
// Serialize Lexical content to HTML (must await the async function)
const contentHtml = await serializeLexical(post.content)
// SEO metadata
const metaTitle = post.meta?.title || post.title
const metaDescription = post.meta?.description || post.excerpt || ''
const metaImage = post.meta?.image?.url || post.ogImage?.url || post.heroImage?.url || ''
---
<Layout title={metaTitle} description={metaDescription}>
<article class="article-detail">
<!-- Article Header -->
<header class="article-header">
<div class="container">
<!-- Category Badge -->
{
category && (
<span
class="article-category"
style={`background-color: ${category.backgroundColor || 'var(--color-enchunblue)'}; color: ${category.textColor || '#fff'}`}
>
{category.title}
</span>
)
}
<!-- Article Title -->
<h1 class="article-title">
{post.title}
</h1>
<!-- Published Date -->
{
displayDate && (
<time class="article-date" datetime={post.publishedAt || post.createdAt}>
{displayDate}
</time>
)
}
</div>
</header>
<!-- Hero Image -->
{
post.heroImage?.url && (
<div class="article-hero-image">
<img
src={post.heroImage.url}
alt={post.heroImage.alt || post.title}
loading="eager"
width="1200"
height="675"
/>
</div>
)
}
<!-- Article Content -->
<div class="article-content">
<div class="container">
<div class="content-wrapper">
<!-- Render rich text content with Lexical to HTML conversion -->
<div class="prose" set:html={contentHtml} />
</div>
</div>
</div>
<!-- Back to List Button -->
<div class="article-actions">
<div class="container">
<a href="/news" class="back-button">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
回到文章列表
</a>
</div>
</div>
</article>
<!-- Related Posts Section -->
<RelatedPosts posts={relatedPosts} />
</Layout>
<!-- Open Graph Tags -->
<script define:vars={{ metaImage, metaTitle, metaDescription }}>
if (typeof document !== 'undefined') {
// Update OG image
const ogImage = document.querySelector('meta[property="og:image"]')
if (ogImage && metaImage) {
ogImage.setAttribute('content', metaImage)
}
// Update OG title
const ogTitle = document.querySelector('meta[property="og:title"]')
if (ogTitle) {
ogTitle.setAttribute('content', metaTitle)
}
// Update OG description
const ogDesc = document.querySelector('meta[property="og:description"]')
if (ogDesc) {
ogDesc.setAttribute('content', metaDescription)
}
}
</script>
<style>
/* Article Header */
.article-header {
padding: 60px 20px 40px;
text-align: center;
background-color: #ffffff;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 0 20px;
}
.article-category {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.article-title {
font-family: "Noto Sans TC", sans-serif;
font-size: 2.5rem;
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
line-height: 1.3;
margin-bottom: 1rem;
}
.article-date {
font-family: "Quicksand", "Noto Sans TC", sans-serif;
font-size: 1rem;
color: var(--color-gray-500, #999);
}
/* Hero Image */
.article-hero-image {
max-width: 1200px;
margin: 0 auto;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-gray-100, #f5f5f5);
}
.article-hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Article Content */
.article-content {
padding: 60px 20px;
background-color: #ffffff;
}
.content-wrapper {
max-width: 720px;
margin: 0 auto;
}
/* Prose styles for rich text content */
.prose {
font-family: "Noto Sans TC", sans-serif;
color: var(--color-gray-700, #333);
line-height: 1.8;
}
.prose :global(h1),
.prose :global(h2),
.prose :global(h3),
.prose :global(h4) {
font-weight: 700;
color: var(--color-dark-blue, #1a1a1a);
margin-top: 2rem;
margin-bottom: 1rem;
line-height: 1.3;
}
.prose :global(h1) { font-size: 2rem; }
.prose :global(h2) { font-size: 1.75rem; }
.prose :global(h3) { font-size: 1.5rem; }
.prose :global(h4) { font-size: 1.25rem; }
.prose :global(p) {
margin-bottom: 1.25rem;
font-size: 1.0625rem;
}
.prose :global(a) {
color: var(--color-enchunblue, #23608c);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(a:hover) {
color: var(--color-enchunblue-hover, #1a4d6e);
}
.prose :global(img) {
border-radius: 8px;
margin: 2rem 0;
max-width: 100%;
height: auto;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.5rem;
}
.prose :global(blockquote) {
border-left: 4px solid var(--color-enchunblue, #23608c);
padding-left: 1rem;
margin: 2rem 0;
font-style: italic;
color: var(--color-gray-600, #666);
}
.prose :global(code) {
background: var(--color-gray-100, #f5f5f5);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
.prose :global(pre) {
background: var(--color-dark-blue, #1a1a1a);
color: #fff;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 2rem 0;
}
.prose :global(pre code) {
background: transparent;
color: inherit;
}
/* Article Actions */
.article-actions {
padding: 20px;
background-color: #f8f9fa;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: #ffffff;
border: 2px solid var(--color-gray-300, #e0e0e0);
border-radius: 8px;
color: var(--color-gray-700, #555);
font-family: "Noto Sans TC", sans-serif;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.25s ease;
}
.back-button:hover {
border-color: var(--color-enchunblue, #23608c);
color: var(--color-enchunblue, #23608c);
background: rgba(35, 96, 140, 0.05);
}
/* Responsive Adjustments */
@media (max-width: 991px) {
.article-title {
font-size: 2rem;
}
}
@media (max-width: 767px) {
.article-header {
padding: 40px 16px 30px;
}
.article-title {
font-size: 1.75rem;
}
.article-hero-image {
aspect-ratio: 4 / 3;
}
.article-content {
padding: 40px 16px;
}
.content-wrapper {
padding: 0;
}
.prose :global(h1) { font-size: 1.75rem; }
.prose :global(h2) { font-size: 1.5rem; }
.prose :global(h3) { font-size: 1.25rem; }
.prose :global(p) {
font-size: 1rem;
}
}
</style>