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>
|