Initial commit

This commit is contained in:
Jennie Robinson Faber 2025-11-11 19:12:21 +00:00
commit 92e96b9107
85 changed files with 24969 additions and 0 deletions

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

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