CloudBridge/pages/blog/[...slug].vue

232 lines
6.8 KiB
Vue
Raw Normal View History

2025-09-04 18:01:27 +08:00
<template>
<div>
<!-- Loading State -->
<div v-if="pending" class="min-h-screen flex items-center justify-center">
<div class="text-center">
<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 article...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-bold text-gray-900 mb-4">Article Not Found</h1>
<p class="text-gray-600 mb-8">The article you're looking for doesn't exist.</p>
<NuxtLink to="/blog" class="btn-primary">
{{ $t('blog.backToBlog') }}
</NuxtLink>
</div>
</div>
<!-- Article Content -->
<article v-else-if="article" class="min-h-screen">
<!-- Article Header -->
<header class="aws-gradient text-white section-padding">
<div class="container-custom">
<div class="max-w-4xl mx-auto">
<div class="flex items-center text-sm text-white/80 mb-4">
<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>
<h1 class="text-4xl md:text-5xl font-bold mb-6">
{{ article.title }}
</h1>
<p v-if="article.description" class="text-xl text-white/90 mb-8">
{{ article.description }}
</p>
<!-- Language Switcher -->
<div class="flex items-center space-x-4">
<span class="text-white/80">Available in:</span>
<div class="flex space-x-2">
<button
v-for="locale in availableLocales"
:key="locale.code"
@click="switchToTranslation(locale.code)"
:class="[
'px-3 py-1 text-sm rounded',
currentLocale === locale.code
? 'bg-white text-aws-orange'
: 'bg-white/20 text-white hover:bg-white/30'
]"
>
{{ locale.name }}
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Article Content -->
<section class="section-padding bg-white">
<div class="container-custom">
<div class="max-w-4xl mx-auto">
<div class="prose prose-lg max-w-none">
<ContentRenderer :value="article" />
</div>
</div>
</div>
</section>
<!-- Navigation -->
<section class="bg-gray-50 section-padding">
<div class="container-custom">
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center">
<NuxtLink to="/blog" class="btn-outline">
{{ $t('blog.backToBlog') }}
</NuxtLink>
<div class="flex space-x-4">
<a
href="https://t.me/pinnovatecloud"
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 hover:text-aws-orange transition-colors duration-200"
>
<MessageCircle class="w-6 h-6" />
</a>
<a
href="https://wa.me/19174029875"
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 hover:text-aws-orange transition-colors duration-200"
>
<Phone class="w-6 h-6" />
</a>
</div>
</div>
</div>
</div>
</section>
</article>
</div>
</template>
<script setup>
import { Calendar, MessageCircle, Phone } from 'lucide-vue-next'
const route = useRoute()
const { locale, locales } = useI18n()
const currentLocale = ref(locale.value)
// Get the slug from the route
const slug = route.params.slug
2025-09-04 19:06:49 +08:00
const base = `/${Array.isArray(slug) ? slug.join('/') : slug}`
2025-09-04 19:15:48 +08:00
// Our list links to /blog/{locale}/{slug}, derive content path
2025-09-04 19:06:49 +08:00
const articlePath = base.replace(/^\/blog(?=\/)/, '')
2025-09-04 19:15:48 +08:00
// Remove leading locale segment for @nuxt/content when using locales option
const normalizedPath = articlePath.replace(/^\/(en|zh|zh-hant)(?=\/)/, '')
2025-09-04 18:01:27 +08:00
2025-09-04 19:15:48 +08:00
// Fetch the article: prefer normalized path with _locale, fallback to original path
2025-09-04 18:01:27 +08:00
const { data: article, pending, error } = await useAsyncData(`article-${articlePath}`, async () => {
try {
2025-09-04 19:15:48 +08:00
const localized = await queryContent(normalizedPath)
.where({ _locale: locale.value })
.findOne()
if (localized) return localized
2025-09-04 18:01:27 +08:00
return await queryContent(articlePath).findOne()
} catch (err) {
console.error('Error fetching article:', err)
throw err
}
})
// SEO
useSeoMeta({
title: () => article.value?.title || 'Article',
description: () => article.value?.description || '',
ogTitle: () => article.value?.title || 'Article',
ogDescription: () => article.value?.description || '',
ogImage: () => article.value?.image || '',
twitterCard: 'summary_large_image'
})
2025-09-04 19:06:49 +08:00
const availableLocales = computed(() => locales.value)
2025-09-04 18:01:27 +08:00
const formatDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const switchToTranslation = async (targetLocale) => {
if (targetLocale === currentLocale.value) return
try {
2025-09-04 19:06:49 +08:00
const base = articlePath.replace(/^\/(en|zh|zh-hant)/,'')
const translated = await queryContent(`/${targetLocale}${base}`).findOne()
2025-09-04 18:01:27 +08:00
if (translated?._path) {
2025-09-04 19:15:48 +08:00
const appPrefix = targetLocale === 'en' ? '' : `/${targetLocale}`
const slugPath = translated._path.replace(/^\/(en|zh|zh-hant)(?=\/)/, '')
await navigateTo(`${appPrefix}/blog${slugPath}`)
2025-09-04 18:01:27 +08:00
return
}
throw new Error('Not found')
} catch (err) {
// Show translation not available message
await navigateTo(`/blog/no-translation?article=${encodeURIComponent(articlePath)}&locale=${targetLocale}`)
}
}
</script>
<style>
.prose {
@apply text-gray-900;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
@apply text-gray-900 font-bold;
}
.prose h1 {
@apply text-3xl;
}
.prose h2 {
@apply text-2xl;
}
.prose h3 {
@apply text-xl;
}
.prose a {
@apply text-aws-orange hover:underline;
}
.prose code {
@apply bg-gray-100 px-2 py-1 rounded text-sm;
}
.prose pre {
@apply bg-gray-900 text-white p-4 rounded-lg overflow-x-auto;
}
.prose blockquote {
@apply border-l-4 border-aws-orange pl-4 italic;
}
.prose ul,
.prose ol {
@apply space-y-2;
}
.prose li {
@apply text-gray-700;
}
</style>