feat(frontend): update pages, components and branding

Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
This commit is contained in:
2026-02-11 11:50:42 +08:00
parent be7fc902fb
commit 9c2181f743
49 changed files with 9699 additions and 899 deletions

View File

@@ -0,0 +1,212 @@
/**
* Blog API utilities
* Helper functions for fetching blog posts and categories from Payload CMS
*/
interface PostImage {
url: string
alt?: string
}
export interface Category {
id: string
title: string
slug: string
backgroundColor?: string
textColor?: string
nameEn?: string
}
export interface Post {
id: string
title: string
slug: string
heroImage?: PostImage | null
ogImage?: PostImage | null
excerpt?: string | null
content?: any
categories?: Category[] | null
relatedPosts?: Post[] | null
publishedAt?: string | null
createdAt?: string | null
updatedAt?: string | null
status?: string
meta?: {
title?: string
description?: string
image?: PostImage
}
}
export interface BlogResponse {
docs: Post[]
totalDocs: number
totalPages: number
page: number
hasPreviousPage: boolean
hasNextPage: boolean
}
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
// Production CMS has 35+ published posts with proper slugs
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
/**
* Fetch published posts from Payload CMS
*/
export async function fetchPosts(
page = 1,
limit = 12,
categorySlug?: string | null
): Promise<BlogResponse> {
try {
// Build query parameters
const params = new URLSearchParams()
if (categorySlug) {
// For category filtering, we need to filter by category
params.append('where[categories][slug][equals]', categorySlug)
}
// Always filter by published status (use _status for Payload's internal status)
params.append('where[_status][equals]', 'published')
params.append('sort', '-publishedAt')
params.append('limit', limit.toString())
params.append('page', page.toString())
params.append('depth', '1')
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`)
}
const data = await response.json()
return data as BlogResponse
} catch (error) {
console.error('Error fetching posts:', error)
return {
docs: [],
totalDocs: 0,
totalPages: 0,
page: 1,
hasPreviousPage: false,
hasNextPage: false
}
}
}
/**
* Fetch a single post by slug
*/
export async function fetchPostBySlug(slug: string): Promise<Post | null> {
try {
const params = new URLSearchParams()
params.append('where[slug][equals]', slug)
params.append('where[_status][equals]', 'published')
params.append('depth', '2')
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch post: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching post by slug:', error)
return null
}
}
/**
* Fetch all categories
*/
export async function fetchCategories(): Promise<Category[]> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/categories?sort=order&limit=20&depth=0`)
if (!response.ok) {
throw new Error(`Failed to fetch categories: ${response.statusText}`)
}
const data = await response.json()
return data.docs || []
} catch (error) {
console.error('Error fetching categories:', error)
return []
}
}
/**
* Fetch category by slug
*/
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/categories?where[slug][equals]=${slug}&depth=0`)
if (!response.ok) {
throw new Error(`Failed to fetch category: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching category by slug:', error)
return null
}
}
/**
* Fetch related posts (same category, excluding current post)
*/
export async function fetchRelatedPosts(
currentPostId: string,
categorySlugs: string[],
limit = 3
): Promise<Post[]> {
try {
if (categorySlugs.length === 0) return []
// Use a simpler approach - fetch published posts and filter
const params = new URLSearchParams()
params.append('where[_status][equals]', 'published')
params.append('limit', '20') // Fetch more to filter client-side
params.append('sort', '-publishedAt')
params.append('depth', '1')
const response = await fetch(`${PAYLOAD_API_URL}/posts?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch related posts: ${response.statusText}`)
}
const data = await response.json()
const allPosts = data.docs || []
// Filter: same category, exclude current, limit
return allPosts
.filter((post: Post) => post.id !== currentPostId)
.filter((post: Post) =>
post.categories?.some((cat: Category) => categorySlugs.includes(cat.slug))
)
.slice(0, limit)
} catch (error) {
console.error('Error fetching related posts:', error)
return []
}
}
/**
* Format date in Traditional Chinese
*/
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}

View File

@@ -0,0 +1,169 @@
// Home Page API Service
// Fetches data from Payload CMS for the homepage
export interface ServiceFeature {
icon?: string
title?: string
description?: string
link?: {
url?: string
}
}
interface PortfolioSection {
headline?: string
subheadline?: string
itemsToShow?: number
}
interface CTASection {
headline?: string
description?: string
buttonText?: string
buttonLink?: string
}
export interface HomeData {
heroHeadline?: string
heroSubheadline?: string
heroDesktopVideo?: {
url?: string
filename?: string
alt?: string
sizes?: {
thumbnail?: {
url?: string
}
}
}
heroMobileVideo?: {
url?: string
filename?: string
alt?: string
}
heroFallbackImage?: {
url?: string
filename?: string
alt?: string
}
heroLogo?: {
url?: string
filename?: string
alt?: string
}
serviceFeatures?: ServiceFeature[]
portfolioSection?: PortfolioSection
ctaSection?: CTASection
}
export interface ServiceItem {
icon?: string
title?: string
description?: string
link?: {
url?: string
}
}
export interface PortfolioItem {
id: string
title: string
slug: string
url?: string
image?: {
url?: string
alt?: string
filename?: string
sizes?: {
thumbnail?: {
url?: string
}
}
}
description?: string
websiteType?: string
tags?: Array<{ tag?: string }>
}
// Get API base URL
function getApiBaseUrl(): string {
if (typeof process !== 'undefined' && process.env?.PAYLOAD_CMS_URL) {
return process.env.PAYLOAD_CMS_URL
}
return import.meta.env.PUBLIC_PAYLOAD_CMS_URL || 'https://enchun-admin.anlstudio.cc'
}
const PAYLOAD_URL = getApiBaseUrl()
const PAYLOAD_API_KEY = import.meta.env.PAYLOAD_CMS_API_KEY
// Build common headers
function getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
if (PAYLOAD_API_KEY) {
headers['Authorization'] = `Bearer ${PAYLOAD_API_KEY}`
}
return headers
}
// Fetch Home global data
export async function getHomeData(draft = false): Promise<HomeData | null> {
try {
const draftParam = draft ? '?draft=true' : ''
const response = await fetch(`${PAYLOAD_URL}/api/globals/home${draftParam}`, {
headers: getHeaders(),
})
if (!response.ok) {
console.error('Failed to fetch home data:', response.status, response.statusText)
return null
}
const data = await response.json()
return data
} catch (error) {
console.error('Error fetching home data:', error)
return null
}
}
// Fetch Portfolio items for preview
export async function getPortfolioPreview(limit = 3): Promise<PortfolioItem[]> {
try {
const response = await fetch(
`${PAYLOAD_URL}/api/portfolio?sort=-updatedAt&limit=${limit}&depth=1`,
{
headers: getHeaders(),
}
)
if (!response.ok) {
console.error('Failed to fetch portfolio items:', response.status)
return []
}
const data = await response.json()
return data.docs || []
} catch (error) {
console.error('Error fetching portfolio items:', error)
return []
}
}
// Combined fetch for homepage
export async function getHomepageData(draft = false): Promise<{
home: HomeData | null
portfolioItems: PortfolioItem[]
}> {
// First fetch home data to get the itemsToShow value
const home = await getHomeData(draft)
const limit = home?.portfolioSection?.itemsToShow || 3
// Then fetch portfolio items with the correct limit
const portfolioItems = await getPortfolioPreview(limit)
return { home, portfolioItems }
}

View File

@@ -0,0 +1,139 @@
/**
* Portfolio API utilities
* Helper functions for fetching portfolio items from Payload CMS
*/
interface PortfolioImage {
url: string
alt?: string
}
export interface PortfolioTag {
id: string
tag: string
}
export interface PortfolioItem {
id: string
title: string
slug: string
url?: string | null
image?: PortfolioImage | null
description?: string | null
websiteType?: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other' | null
tags?: PortfolioTag[] | null
createdAt?: string | null
updatedAt?: string | null
}
export interface PortfolioResponse {
docs: PortfolioItem[]
totalDocs: number
totalPages: number
page: number
hasPreviousPage: boolean
hasNextPage: boolean
}
// Use production CMS API (enchun-cms.anlstudio.cc) for all environments
const PAYLOAD_API_URL = 'https://enchun-cms.anlstudio.cc/api'
/**
* Fetch all portfolio items
*/
export async function fetchPortfolios(
page = 1,
limit = 100
): Promise<PortfolioResponse> {
try {
const params = new URLSearchParams({
sort: '-createdAt',
limit: limit.toString(),
page: page.toString(),
depth: '1'
})
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch portfolios: ${response.statusText}`)
}
const data = await response.json()
return data as PortfolioResponse
} catch (error) {
console.error('Error fetching portfolios:', error)
return {
docs: [],
totalDocs: 0,
totalPages: 0,
page: 1,
hasPreviousPage: false,
hasNextPage: false
}
}
}
/**
* Fetch a single portfolio item by slug
*/
export async function fetchPortfolioBySlug(slug: string): Promise<PortfolioItem | null> {
try {
const response = await fetch(`${PAYLOAD_API_URL}/portfolio?where[slug][equals]=${slug}&depth=1`)
if (!response.ok) {
throw new Error(`Failed to fetch portfolio: ${response.statusText}`)
}
const data = await response.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching portfolio by slug:', error)
return null
}
}
/**
* Get website type label in Traditional Chinese
*/
export function getWebsiteTypeLabel(type: string | null | undefined): string {
const labels: Record<string, string> = {
corporate: '企業官網',
ecommerce: '電商網站',
landing: '活動頁面',
brand: '品牌網站',
other: '其他'
}
return labels[type || ''] || '其他'
}
/**
* Get all tags from portfolio items
*/
export function getAllTags(portfolios: PortfolioItem[]): string[] {
const tagSet = new Set<string>()
portfolios.forEach(item => {
item.tags?.forEach(tagObj => {
tagSet.add(tagObj.tag)
})
})
return Array.from(tagSet).sort()
}
/**
* Filter portfolios by website type
*/
export function filterByType(portfolios: PortfolioItem[], type: string): PortfolioItem[] {
if (!type || type === 'all') return portfolios
return portfolios.filter(item => item.websiteType === type)
}
/**
* Filter portfolios by tag
*/
export function filterByTag(portfolios: PortfolioItem[], tag: string): PortfolioItem[] {
if (!tag) return portfolios
return portfolios.filter(item =>
item.tags?.some(tagObj => tagObj.tag === tag)
)
}

View File

@@ -0,0 +1,221 @@
/**
* Tests for serializeLexical utility
*/
import { describe, it, expect } from 'vitest'
import { serializeLexical } from './serializeLexical'
describe('serializeLexical', () => {
it('should return empty string for null input', async () => {
const result = await serializeLexical(null)
expect(result).toBe('')
})
it('should return empty string for undefined input', async () => {
const result = await serializeLexical(undefined)
expect(result).toBe('')
})
it('should return empty string for empty string', async () => {
const result = await serializeLexical('')
expect(result).toBe('')
})
it('should return plain text as-is', async () => {
const result = await serializeLexical('Hello world')
expect(result).toBe('Hello world')
})
it('should return HTML as-is', async () => {
const html = '<p>Hello <strong>world</strong></p>'
const result = await serializeLexical(html)
expect(result).toBe(html)
})
it('should convert Lexical JSON to HTML - simple paragraph', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Hello world'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('Hello world')
expect(result).toContain('<p>')
expect(result).toContain('</p>')
})
it('should convert Lexical JSON with heading', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'heading',
tag: 'h2',
children: [
{
type: 'text',
text: 'Title'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<h2>')
expect(result).toContain('Title')
expect(result).toContain('</h2>')
})
it('should convert Lexical JSON with bold text', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Hello',
bold: true
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<strong>')
expect(result).toContain('Hello')
expect(result).toContain('</strong>')
})
it('should convert Lexical JSON with link', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'link',
url: 'https://example.com',
children: [
{
type: 'text',
text: 'Click here'
}
]
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<a href="https://example.com"')
expect(result).toContain('Click here')
expect(result).toContain('</a>')
})
it('should handle stringified Lexical JSON', async () => {
const lexicalString = JSON.stringify({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Test content'
}
]
}
]
}
})
const result = await serializeLexical(lexicalString)
expect(result).toContain('Test content')
})
it('should handle array input gracefully', async () => {
const result = await serializeLexical(['not', 'valid'])
expect(result).toBe('')
})
it('should convert list nodes', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'list',
listType: 'bullet',
children: [
{
type: 'listitem',
children: [
{
type: 'text',
text: 'Item 1'
}
]
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
expect(result).toContain('Item 1')
expect(result).toContain('</li>')
expect(result).toContain('</ul>')
})
it('should handle blockquote', async () => {
const lexicalData = {
root: {
type: 'root',
children: [
{
type: 'quote',
children: [
{
type: 'text',
text: 'Quote text'
}
]
}
]
}
}
const result = await serializeLexical(lexicalData)
expect(result).toContain('<blockquote>')
expect(result).toContain('Quote text')
expect(result).toContain('</blockquote>')
})
})

View File

@@ -0,0 +1,357 @@
/**
* Lexical to HTML Converter
* Converts Payload CMS Lexical editor JSON format to HTML
*
* This implementation uses a simple recursive renderer that works in
* both browser and SSR environments without requiring heavy dependencies.
*/
/**
* Serialized Lexical node types
*/
interface LexicalTextNode {
type: 'text'
text: string
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
code?: boolean
}
interface LexicalHeadingNode {
type: 'heading'
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
children: any[]
}
interface LexicalParagraphNode {
type: 'paragraph'
children: any[]
}
interface LexicalListNode {
type: 'list'
listType: 'bullet' | 'number'
children: any[]
}
interface LexicalListItemNode {
type: 'listitem'
children: any[]
}
interface LexicalLinkNode {
type: 'link'
url: string
title?: string
children: any[]
}
interface LexicalQuoteNode {
type: 'quote'
children: any[]
}
interface LexicalLineBreakNode {
type: 'linebreak'
}
interface LexicalUploadNode {
type: 'upload'
value?: {
url: string
alt?: string
width?: number
height?: number
}
}
interface LexicalBlockNode {
type: 'block'
fields?: {
[key: string]: any
}
}
interface LexicalRootNode {
type: 'root'
children: any[]
}
interface SerializedEditorState {
root?: LexicalRootNode
}
/**
* Convert Lexical JSON to HTML string
*
* @param lexicalData - The Lexical editor state from Payload CMS (object or JSON string)
* @returns HTML string
*/
export async function serializeLexical(lexicalData: unknown): Promise<string> {
// Handle null, undefined, empty values
if (lexicalData === null || lexicalData === undefined) {
return ''
}
// Handle string content (already HTML or JSON string)
if (typeof lexicalData === 'string') {
const trimmed = lexicalData.trim()
// Empty string
if (trimmed === '') {
return ''
}
// Check if it's a JSON object (Lexical format)
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as SerializedEditorState | LexicalRootNode
return renderLexical(parsed)
} catch {
// Not valid JSON, return as-is (might be HTML already)
return trimmed
}
}
// Already HTML or plain text
return trimmed
}
// Handle object (Lexical editor state)
if (typeof lexicalData === 'object') {
// Ensure it's not an array
if (Array.isArray(lexicalData)) {
return ''
}
return renderLexical(lexicalData as SerializedEditorState | LexicalRootNode)
}
return ''
}
/**
* Render Lexical editor state to HTML
*/
function renderLexical(state: SerializedEditorState | LexicalRootNode): string {
if (!state) return ''
// Get root children - handle both formats
let children: any[] = []
if ('root' in state && state.root?.children) {
children = state.root.children
} else if ('children' in state && Array.isArray(state.children)) {
children = state.children
}
return children
.map((child) => renderNode(child))
.filter(Boolean)
.join('')
}
/**
* Render a single Lexical node to HTML
*/
function renderNode(node: any): string {
if (!node || typeof node !== 'object') return ''
const type = node.type
switch (type) {
case 'root':
return (node.children || []).map((child: any) => renderNode(child)).join('')
case 'text':
return renderTextNode(node)
case 'paragraph':
return renderParagraphNode(node)
case 'heading':
return renderHeadingNode(node)
case 'link':
return renderLinkNode(node)
case 'list':
return renderListNode(node)
case 'listitem':
return renderListItemNode(node)
case 'quote':
return renderQuoteNode(node)
case 'linebreak':
return '<br>'
case 'upload':
case 'image':
return renderUploadNode(node)
case 'block':
return renderBlockNode(node)
default:
// Unknown node type - try to render children if available
if (node.children && Array.isArray(node.children)) {
return node.children.map((child: any) => renderNode(child)).join('')
}
return ''
}
}
/**
* Render text node with formatting
*/
function renderTextNode(node: LexicalTextNode): string {
let text = escapeHtml(node.text || '')
// Apply formatting styles (order matters for nested tags)
if (node.code) text = `<code>${text}</code>`
if (node.strikethrough) text = `<s>${text}</s>`
if (node.underline) text = `<u>${text}</u>`
if (node.italic) text = `<em>${text}</em>`
if (node.bold) text = `<strong>${text}</strong>`
return text
}
/**
* Render paragraph node
*/
function renderParagraphNode(node: LexicalParagraphNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<p>${children}</p>` : ''
}
/**
* Render heading node
*/
function renderHeadingNode(node: LexicalHeadingNode): string {
const tag = node.tag || 'h2'
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<${tag}>${children}</${tag}>` : ''
}
/**
* Render link node
*/
function renderLinkNode(node: LexicalLinkNode): string {
const url = escapeHtml(node.url || '#')
const title = node.title ? ` title="${escapeHtml(node.title)}"` : ''
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<a href="${url}"${title}>${children}</a>` : ''
}
/**
* Render list node
*/
function renderListNode(node: LexicalListNode): string {
const listType = node.listType === 'number' ? 'ol' : 'ul'
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<${listType}>${children}</${listType}>` : ''
}
/**
* Render list item node
*/
function renderListItemNode(node: LexicalListItemNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<li>${children}</li>` : ''
}
/**
* Render quote node
*/
function renderQuoteNode(node: LexicalQuoteNode): string {
const children = (node.children || []).map((child) => renderNode(child)).join('')
return children ? `<blockquote>${children}</blockquote>` : ''
}
/**
* Render upload/image node
*/
function renderUploadNode(node: LexicalUploadNode): string {
const url = node.value?.url || ''
const alt = escapeHtml(node.value?.alt || '')
const width = node.value?.width ? ` width="${node.value.width}"` : ''
const height = node.value?.height ? ` height="${node.value.height}"` : ''
if (!url) return ''
return `<img src="${escapeHtml(url)}" alt="${alt}"${width}${height} style="max-width: 100%; height: auto;" loading="lazy" />`
}
/**
* Render block node (for custom blocks like media, banners, etc.)
*/
function renderBlockNode(node: LexicalBlockNode): string {
const fields = node.fields || {}
// Handle media block
if (fields.media) {
const media = fields.media
const url = media.url || ''
const alt = escapeHtml(media.alt || '')
if (url) {
return `<figure><img src="${escapeHtml(url)}" alt="${alt}" style="max-width: 100%; height: auto; border-radius: 8px; margin: 2rem 0;" loading="lazy" /></figure>`
}
}
// Handle other block types
if (fields.blockType === 'banner') {
const content = fields.content || ''
return `<div class="banner">${renderNode({ type: 'root', children: content.root?.children || [] })}</div>`
}
if (fields.blockType === 'code') {
const code = escapeHtml(fields.code || '')
const language = escapeHtml(fields.language || '')
return `<pre><code class="language-${language}">${code}</code></pre>`
}
// Fallback: try to render any nested content
if (fields.content?.root?.children) {
return fields.content.root.children.map((child: any) => renderNode(child)).join('')
}
return ''
}
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return text.replace(/[&<>"']/g, (m) => map[m] || m)
}
/**
* Synchronous version for compatibility (not recommended for use with Lexical)
* @deprecated Use serializeLexical instead
*/
export function serializeLexicalSync(lexicalData: unknown): string {
// This is a stub - the actual implementation should be async
// For simple cases, we'll try to return something useful
if (!lexicalData || typeof lexicalData !== 'object') {
return String(lexicalData ?? '')
}
if (typeof lexicalData === 'string') {
return lexicalData
}
// For Lexical objects, return empty since we can't properly render synchronously
return ''
}