Initial commit
This commit is contained in:
commit
92e96b9107
85 changed files with 24969 additions and 0 deletions
163
app/pages/articles/[slug].vue
Normal file
163
app/pages/articles/[slug].vue
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="text-center py-8">
|
||||
<div class="text-gray-600 dark:text-gray-400">Loading article...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="!article" class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400">Article not found</div>
|
||||
<NuxtLink
|
||||
to="/articles"
|
||||
class="text-blue-600 hover:text-blue-800 mt-4 inline-block"
|
||||
>
|
||||
← Back to Articles
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Article Content -->
|
||||
<article v-else class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-6 mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span>By {{ article.author || "Unknown" }}</span>
|
||||
<span v-if="article.publishedAt">
|
||||
{{ new Date(article.publishedAt).toLocaleDateString() }}
|
||||
</span>
|
||||
<span
|
||||
v-if="article.category"
|
||||
class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded"
|
||||
>
|
||||
{{ article.category }}
|
||||
</span>
|
||||
<span
|
||||
v-if="article.accessLevel && article.accessLevel !== 'public'"
|
||||
class="text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
🔒 {{ article.accessLevel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Edit Button (for future use) -->
|
||||
<!-- <div v-if="canEdit" class="mt-4">
|
||||
<NuxtLink
|
||||
:to="`${article._path}/edit`"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Edit Article
|
||||
</NuxtLink>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- Access Control Warning -->
|
||||
<div
|
||||
v-if="article.accessLevel === 'member'"
|
||||
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p class="text-blue-800 dark:text-blue-200">
|
||||
👥 This content is for Baby Ghosts members only
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="article.accessLevel === 'cohort'"
|
||||
class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p class="text-purple-800 dark:text-purple-200">
|
||||
🔒 This content is for a specific cohort
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="article.accessLevel === 'admin'"
|
||||
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p class="text-red-800 dark:text-red-200">
|
||||
🔐 This is internal/admin content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Article Body -->
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<ContentRenderer :value="article" />
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div
|
||||
v-if="article.tags?.length"
|
||||
class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in article.tags"
|
||||
:key="tag"
|
||||
class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full text-sm"
|
||||
>
|
||||
#{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div
|
||||
class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 flex gap-4"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/articles"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
← Back to Articles
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
const slug = route.params.slug;
|
||||
|
||||
const getArticleSlug = (article) => {
|
||||
if (!article) return "";
|
||||
const candidate =
|
||||
article.slug ||
|
||||
article.stem ||
|
||||
article._path ||
|
||||
article._id ||
|
||||
article.id ||
|
||||
"";
|
||||
const segments = candidate.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] || "";
|
||||
};
|
||||
|
||||
// Fetch article from Nuxt Content
|
||||
const { data: article, pending } = await useAsyncData(
|
||||
`article-${slug}`,
|
||||
async () => {
|
||||
const articles = await queryCollection("articles").all();
|
||||
return articles.find((a) => getArticleSlug(a) === slug);
|
||||
},
|
||||
);
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => article.value?.title || "Article",
|
||||
meta: [
|
||||
{ name: "description", content: () => article.value?.description || "" },
|
||||
],
|
||||
});
|
||||
|
||||
// Watch for route changes
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
() => {
|
||||
// Route change will trigger new useAsyncData
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
158
app/pages/articles/index.vue
Normal file
158
app/pages/articles/index.vue
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Articles
|
||||
</h1>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow mb-6">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search articles..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="operations">Operations</option>
|
||||
<option value="funding">Funding</option>
|
||||
<option value="strategy">Strategy</option>
|
||||
<option value="templates">Templates</option>
|
||||
<option value="programs">Programs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Articles List -->
|
||||
<div v-if="filteredArticles.length" class="space-y-4">
|
||||
<article
|
||||
v-for="article in filteredArticles"
|
||||
:key="article._path || article.stem || getArticleSlug(article)"
|
||||
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<NuxtLink
|
||||
:to="getArticlePath(article)"
|
||||
class="text-xl font-semibold text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-3 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded">
|
||||
{{ article.category || "uncategorized" }}
|
||||
</span>
|
||||
<span>By {{ article.author || "Unknown" }}</span>
|
||||
<span v-if="article.publishedAt">
|
||||
{{ new Date(article.publishedAt).toLocaleDateString() }}
|
||||
</span>
|
||||
<span
|
||||
v-if="article.accessLevel && article.accessLevel !== 'public'"
|
||||
class="text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
🔒 {{ article.accessLevel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{
|
||||
search || selectedCategory
|
||||
? "No articles found matching your filters"
|
||||
: "No articles available"
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const search = ref("");
|
||||
const selectedCategory = ref("");
|
||||
|
||||
// Fetch all articles from Nuxt Content
|
||||
const { data: articles } = await useAsyncData("articles", () =>
|
||||
queryCollection("articles").all(),
|
||||
);
|
||||
|
||||
// Helper function to extract title from body
|
||||
const getArticleTitle = (article) => {
|
||||
// Try to get title from frontmatter first
|
||||
if (
|
||||
article.title &&
|
||||
typeof article.title === "string" &&
|
||||
article.title !== "[object Object]"
|
||||
) {
|
||||
return article.title;
|
||||
}
|
||||
|
||||
// Fallback: extract from first H1 in body
|
||||
if (article.meta?.body?.value && Array.isArray(article.meta.body.value)) {
|
||||
const h1 = article.meta.body.value.find(
|
||||
(node) => Array.isArray(node) && node[0] === "h1",
|
||||
);
|
||||
if (h1 && h1[2]) {
|
||||
return h1[2];
|
||||
}
|
||||
}
|
||||
|
||||
return "Untitled";
|
||||
};
|
||||
|
||||
// Resolve the correct Nuxt route for an article entry
|
||||
const getArticleSlug = (article) => {
|
||||
if (!article) return "";
|
||||
const candidate =
|
||||
article.slug ||
|
||||
article.stem ||
|
||||
article._path ||
|
||||
article._id ||
|
||||
article.id ||
|
||||
"";
|
||||
const segments = candidate.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] || "";
|
||||
};
|
||||
|
||||
const getArticlePath = (article) => {
|
||||
const slug = getArticleSlug(article);
|
||||
return slug ? `/articles/${slug}` : "/articles";
|
||||
};
|
||||
|
||||
// Filter and search articles
|
||||
const filteredArticles = computed(() => {
|
||||
if (!articles.value) return [];
|
||||
|
||||
return articles.value.filter((article) => {
|
||||
const title = getArticleTitle(article);
|
||||
const matchesSearch =
|
||||
!search.value ||
|
||||
title?.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
article.description?.toLowerCase().includes(search.value.toLowerCase());
|
||||
|
||||
const matchesCategory =
|
||||
!selectedCategory.value || article.category === selectedCategory.value;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
});
|
||||
|
||||
// Watch for search changes
|
||||
watch(search, () => {
|
||||
// Trigger reactivity
|
||||
});
|
||||
</script>
|
||||
228
app/pages/articles/new.vue
Normal file
228
app/pages/articles/new.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Create New Article</h1>
|
||||
|
||||
<form @submit.prevent="createArticle" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<!-- Title -->
|
||||
<div class="mb-6">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
id="title"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Article title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div class="mb-6">
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
URL Slug
|
||||
</label>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
id="slug"
|
||||
type="text"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="article-url-slug"
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
id="description"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Brief description of the article"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="mb-6">
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
v-model="form.category"
|
||||
id="category"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="operations">Operations</option>
|
||||
<option value="funding">Funding</option>
|
||||
<option value="strategy">Strategy</option>
|
||||
<option value="templates">Templates</option>
|
||||
<option value="programs">Programs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Access Level -->
|
||||
<div class="mb-6">
|
||||
<label for="accessLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Level
|
||||
</label>
|
||||
<select
|
||||
v-model="form.accessLevel"
|
||||
id="accessLevel"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="member">Members Only</option>
|
||||
<option value="cohort">Cohort Only</option>
|
||||
<option value="admin" v-if="hasPermission('canAdmin')">Admin Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="mb-6">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
id="content"
|
||||
rows="20"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="Write your article in Markdown..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="mb-6">
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
v-model="tagsInput"
|
||||
id="tags"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="tag1, tag2, tag3"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.status"
|
||||
type="radio"
|
||||
value="draft"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">Save as Draft</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.status"
|
||||
type="radio"
|
||||
value="published"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">Publish Now</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Creating...' : 'Create Article' }}
|
||||
</button>
|
||||
<NuxtLink to="/articles" class="bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300 px-6 py-2 rounded-md hover:bg-gray-400 dark:hover:bg-gray-500">
|
||||
Cancel
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { hasPermission, isAuthenticated } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
// Redirect if not authenticated
|
||||
if (!isAuthenticated.value) {
|
||||
navigateTo('/api/auth/login', { external: true })
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
category: 'operations',
|
||||
accessLevel: 'member',
|
||||
content: '',
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
const tagsInput = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Auto-generate slug from title
|
||||
watch(() => form.value.title, (title) => {
|
||||
if (!form.value.slug || form.value.slug === slugify(title.slice(0, -1))) {
|
||||
form.value.slug = slugify(title)
|
||||
}
|
||||
})
|
||||
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
async function createArticle() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const tags = tagsInput.value
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t)
|
||||
|
||||
const response = await $fetch('/api/articles', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...form.value,
|
||||
tags
|
||||
}
|
||||
})
|
||||
|
||||
// Redirect to the new article
|
||||
await router.push(`/articles/${response.slug}`)
|
||||
} catch (err) {
|
||||
error.value = err.data?.message || 'Failed to create article'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
108
app/pages/index.vue
Normal file
108
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Ghost Guild Knowledge Commons
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
|
||||
Collaborative knowledge base for the Baby Ghosts community
|
||||
</p>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-2">📚 Browse Articles</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Explore our growing collection of guides and resources
|
||||
</p>
|
||||
<NuxtLink to="/articles" class="text-blue-600 hover:text-blue-800">
|
||||
View All Articles →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAuthenticated"
|
||||
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-2">✍️ Contribute</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Share your knowledge with the community
|
||||
</p>
|
||||
<NuxtLink to="/articles/new" class="text-blue-600 hover:text-blue-800">
|
||||
Create Article →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-2">👥 Community</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Connect with other Baby Ghosts members
|
||||
</p>
|
||||
<a
|
||||
href="https://ghostguild.org"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Visit Ghost Guild →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Articles -->
|
||||
<div class="mt-12">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Recent Articles
|
||||
</h2>
|
||||
<div v-if="pending" class="text-gray-600 dark:text-gray-400">
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else-if="articles?.length" class="space-y-4">
|
||||
<article
|
||||
v-for="article in articles"
|
||||
:key="article.slug"
|
||||
class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="`/articles/${article.slug}`"
|
||||
class="text-xl font-semibold text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
<span>{{ article.category }}</span> •
|
||||
<span>{{
|
||||
new Date(article.publishedAt).toLocaleDateString()
|
||||
}}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// Fetch recent articles from Nuxt Content to keep generate static-friendly
|
||||
const { data: recentArticles, pending } = await useAsyncData(
|
||||
"recent-articles",
|
||||
async () => {
|
||||
const entries = await queryCollection("articles").all();
|
||||
|
||||
const getTimestamp = (value) => {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
||||
};
|
||||
|
||||
return entries
|
||||
.filter((article) => article.published !== false)
|
||||
.sort((a, b) => getTimestamp(b.publishedAt) - getTimestamp(a.publishedAt))
|
||||
.slice(0, 5);
|
||||
},
|
||||
);
|
||||
|
||||
const articles = computed(() => recentArticles.value || []);
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue