170 lines
5.6 KiB
Vue
170 lines
5.6 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Hero Section -->
|
|
<section class="aws-gradient text-white section-padding">
|
|
<div class="container-custom text-center">
|
|
<h1 class="text-5xl font-bold mb-6">
|
|
{{ $t('blog.title') }}
|
|
</h1>
|
|
<p class="text-xl text-white/90 max-w-3xl mx-auto">
|
|
{{ $t('blog.subtitle') }}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Blog Posts -->
|
|
<section class="section-padding bg-white">
|
|
<div class="container-custom">
|
|
<div v-if="pending" class="text-center py-12">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-aws-orange mx-auto"></div>
|
|
<p class="mt-4 text-gray-600">Loading articles...</p>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="text-center py-12">
|
|
<p class="text-red-600">Error loading articles. Please try again later.</p>
|
|
</div>
|
|
|
|
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
<article
|
|
v-for="article in articles"
|
|
:key="article._path"
|
|
class="card hover:shadow-aws transition-all duration-300 group"
|
|
>
|
|
<div v-if="article.image" class="aspect-video bg-gray-200 rounded-lg mb-4 overflow-hidden">
|
|
<img
|
|
:src="article.image"
|
|
:alt="article.title"
|
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
/>
|
|
</div>
|
|
|
|
<div class="p-6">
|
|
<div class="flex items-center text-sm text-gray-500 mb-3">
|
|
<Calendar class="w-4 h-4 mr-2" />
|
|
<time>{{ formatDate(article.date) }}</time>
|
|
<span class="mx-2">•</span>
|
|
<span>{{ article.readingTime }} min read</span>
|
|
</div>
|
|
|
|
<h2 class="text-xl font-bold mb-3 group-hover:text-aws-orange transition-colors duration-200">
|
|
<NuxtLink :to="articleLink(article)">
|
|
{{ article.title }}
|
|
</NuxtLink>
|
|
</h2>
|
|
|
|
<p class="text-gray-600 mb-4 line-clamp-3">
|
|
{{ article.description }}
|
|
</p>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<NuxtLink
|
|
:to="articleLink(article)"
|
|
class="text-aws-orange hover:underline font-medium"
|
|
>
|
|
{{ $t('blog.readMore') }}
|
|
</NuxtLink>
|
|
|
|
<div class="flex space-x-2">
|
|
<button
|
|
v-for="locale in availableLocales"
|
|
:key="locale.code"
|
|
@click="switchToTranslation(article, locale.code)"
|
|
:class="[
|
|
'px-2 py-1 text-xs rounded',
|
|
currentLocale === locale.code
|
|
? 'bg-aws-orange text-white'
|
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
|
]"
|
|
>
|
|
{{ locale.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-if="articles.length === 0" class="text-center py-12">
|
|
<p class="text-gray-600">No articles found.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Calendar } from 'lucide-vue-next'
|
|
|
|
// i18n
|
|
const { t, locale } = useI18n()
|
|
|
|
// SEO
|
|
useSeoMeta({
|
|
title: () => t('seo.blog.title'),
|
|
description: () => t('seo.blog.description')
|
|
})
|
|
|
|
const { locales } = useI18n()
|
|
const currentLocale = ref(locale.value)
|
|
|
|
// Fetch blog articles (filter by current _locale provided by @nuxt/content)
|
|
const { data: articles, pending, error } = await useAsyncData(() => `blog-${locale.value}`, async () => {
|
|
try {
|
|
return await queryContent()
|
|
.where({ _locale: locale.value })
|
|
.sort({ date: -1 })
|
|
.find()
|
|
} catch (err) {
|
|
console.error('Error fetching articles:', err)
|
|
return []
|
|
}
|
|
})
|
|
// Build localized link for article (app-level locale prefix + blog + slug without locale)
|
|
const articleLink = (a) => {
|
|
if (!a || !a._path) return '/blog'
|
|
const appPrefix = locale.value === 'en' ? '' : `/${locale.value}`
|
|
const slugPath = a._path.replace(/^\/(en|zh|zh-hant)(?=\/)/, '')
|
|
return `${appPrefix}/blog${slugPath}`
|
|
}
|
|
|
|
const availableLocales = computed(() =>
|
|
locales.value.filter(locale => locale.code !== 'en' || locale.code === currentLocale.value)
|
|
)
|
|
|
|
const formatDate = (date) => {
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})
|
|
}
|
|
|
|
const switchToTranslation = async (article, targetLocale) => {
|
|
if (targetLocale === currentLocale.value) return
|
|
|
|
try {
|
|
const basePath = article._path.replace(/^\/(en|zh|zh-hant)/, '')
|
|
const translated = await queryContent(`/${targetLocale}${basePath}`).findOne()
|
|
if (translated?._path) {
|
|
const appPrefix = targetLocale === 'en' ? '' : `/${targetLocale}`
|
|
const slugPath = translated._path.replace(/^\/(en|zh|zh-hant)(?=\/)/, '')
|
|
await navigateTo(`${appPrefix}/blog${slugPath}`)
|
|
return
|
|
}
|
|
throw new Error('Not found')
|
|
} catch (err) {
|
|
// Show translation not available message
|
|
await navigateTo(`/blog/no-translation?article=${encodeURIComponent(article._path)}&locale=${targetLocale}`)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.line-clamp-3 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|