158 lines
4.8 KiB
Vue
158 lines
4.8 KiB
Vue
<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>
|