Initial commit
This commit is contained in:
commit
92e96b9107
85 changed files with 24969 additions and 0 deletions
14
app/app.vue
Normal file
14
app/app.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Initialize auth on app mount
|
||||
const { fetchUser } = useAuth()
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUser()
|
||||
})
|
||||
</script>
|
||||
56
app/assets/css/main.css
Normal file
56
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@layer components {
|
||||
/* Custom prose styles */
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
}
|
||||
|
||||
/* Article content styles */
|
||||
.article-content {
|
||||
@apply max-w-none leading-relaxed;
|
||||
}
|
||||
|
||||
.article-content h1 {
|
||||
@apply text-3xl font-bold mb-4 mt-6;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
@apply text-2xl font-semibold mb-3 mt-8;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
@apply text-xl font-semibold mb-2 mt-6;
|
||||
}
|
||||
|
||||
.article-content p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
@apply mb-4 ml-6;
|
||||
}
|
||||
|
||||
.article-content li {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.article-content code {
|
||||
@apply bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-sm font-mono;
|
||||
}
|
||||
|
||||
.article-content pre {
|
||||
@apply bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto mb-4;
|
||||
}
|
||||
|
||||
/* Editor styles */
|
||||
.editor-container {
|
||||
@apply min-h-[500px] border rounded-lg;
|
||||
}
|
||||
|
||||
/* Comment thread styles */
|
||||
.comment-thread {
|
||||
@apply border-l-2 border-gray-200 dark:border-gray-700 pl-4 ml-4;
|
||||
}
|
||||
}
|
||||
70
app/composables/useAuth.ts
Normal file
70
app/composables/useAuth.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { User } from "../types/auth";
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = useState<User | null>("auth.user", () => null);
|
||||
const isAuthenticated = computed(() => !!user.value);
|
||||
|
||||
const login = () => {
|
||||
// Redirect to login endpoint
|
||||
navigateTo("/api/auth/login", { external: true });
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch("/api/auth/logout", { method: "POST" });
|
||||
user.value = null;
|
||||
await navigateTo("/");
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await $fetch<User>("/api/auth/me");
|
||||
user.value = userData;
|
||||
return userData;
|
||||
} catch (error) {
|
||||
user.value = null;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user.value?.roles?.includes(role) || false;
|
||||
};
|
||||
|
||||
const hasPermission = (
|
||||
permission: "canEdit" | "canModerate" | "canAdmin",
|
||||
) => {
|
||||
return user.value?.permissions?.[permission] || false;
|
||||
};
|
||||
|
||||
const canAccessContent = (accessLevel: string, cohorts?: string[]) => {
|
||||
if (accessLevel === "public") return true;
|
||||
if (!isAuthenticated.value) return false;
|
||||
|
||||
switch (accessLevel) {
|
||||
case "member":
|
||||
return true;
|
||||
case "cohort":
|
||||
if (!cohorts || cohorts.length === 0) return true;
|
||||
return cohorts.some((c) => hasRole(`cohort-${c}`));
|
||||
case "admin":
|
||||
return hasPermission("canAdmin");
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
login,
|
||||
logout,
|
||||
fetchUser,
|
||||
hasRole,
|
||||
hasPermission,
|
||||
canAccessContent,
|
||||
};
|
||||
};
|
||||
45
app/layouts/default.vue
Normal file
45
app/layouts/default.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white dark:bg-gray-800 shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<NuxtLink to="/" class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Wiki.GhostGuild
|
||||
</NuxtLink>
|
||||
<div class="ml-10 flex space-x-4">
|
||||
<NuxtLink to="/articles" class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md">
|
||||
Articles
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isAuthenticated" to="/articles/new" class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md">
|
||||
New Article
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div v-if="isAuthenticated" class="flex items-center space-x-4">
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ user?.displayName }}</span>
|
||||
<button @click="logout" class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<button v-else @click="login" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAuthenticated, login, logout } = useAuth()
|
||||
</script>
|
||||
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>
|
||||
31
app/server/api/articles/[slug].delete.ts
Normal file
31
app/server/api/articles/[slug].delete.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Article } from "../../models/Article";
|
||||
import { requireRole } from "../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, "slug");
|
||||
|
||||
// Only admins can delete articles
|
||||
const user = await requireRole(event, ["admin"]);
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Slug is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Find and delete article
|
||||
const article = await Article.findOneAndDelete({ slug });
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Article not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Article deleted successfully",
|
||||
};
|
||||
});
|
||||
71
app/server/api/articles/[slug].get.ts
Normal file
71
app/server/api/articles/[slug].get.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { checkAccessLevel, verifyAuth } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Slug is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Find article
|
||||
const article = await Article.findOne({ slug })
|
||||
.populate('author', 'username displayName avatar')
|
||||
.populate('contributors', 'username displayName avatar')
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Article not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await checkAccessLevel(
|
||||
event,
|
||||
article.accessLevel,
|
||||
article.cohorts
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
// For protected content, return limited preview
|
||||
if (article.accessLevel !== 'admin') {
|
||||
return {
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description.substring(0, 200) + '...',
|
||||
category: article.category,
|
||||
tags: article.tags,
|
||||
accessLevel: article.accessLevel,
|
||||
restricted: true,
|
||||
message: 'This content requires authentication'
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied'
|
||||
})
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
article.views += 1
|
||||
await article.save()
|
||||
|
||||
// Get user to check if they've liked
|
||||
const user = await verifyAuth(event)
|
||||
const userLiked = false // TODO: Implement likes tracking
|
||||
|
||||
return {
|
||||
...article.toObject(),
|
||||
userLiked,
|
||||
revisionCount: article.revisions.length,
|
||||
commentCount: article.comments.length,
|
||||
// Don't send full revisions and comments in main response
|
||||
revisions: undefined,
|
||||
comments: undefined
|
||||
}
|
||||
})
|
||||
109
app/server/api/articles/[slug].put.ts
Normal file
109
app/server/api/articles/[slug].put.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Article } from "../../models/Article";
|
||||
import { requireAuth } from "../../utils/auth";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, "slug");
|
||||
const user = await requireAuth(event);
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Slug is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Find article
|
||||
const article = await Article.findOne({ slug });
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Article not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
const isAuthor = article.author.toString() === user.userId;
|
||||
const isAdmin = user.permissions.canAdmin;
|
||||
const isModerator = user.permissions.canModerate;
|
||||
const canEdit = user.permissions.canEdit;
|
||||
|
||||
if (!isAuthor && !isAdmin && !isModerator) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "You do not have permission to edit this article",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if article is locked by another user
|
||||
if (article.lockedBy && article.lockedBy.toString() !== user.userId) {
|
||||
const lockExpired =
|
||||
article.lockedAt &&
|
||||
new Date().getTime() - article.lockedAt.getTime() > 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
if (!lockExpired) {
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
statusMessage: "Article is currently being edited by another user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (body.title) article.title = body.title;
|
||||
if (body.description) article.description = body.description;
|
||||
if (body.content) {
|
||||
// Add to revision history
|
||||
article.revisions.push({
|
||||
content: body.content,
|
||||
author: new Types.ObjectId(user.userId),
|
||||
message: body.revisionMessage || "Content updated",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
article.content = body.content;
|
||||
}
|
||||
if (body.category) article.category = body.category;
|
||||
if (body.tags) article.tags = body.tags;
|
||||
if (body.accessLevel && (isAdmin || isModerator)) {
|
||||
article.accessLevel = body.accessLevel;
|
||||
}
|
||||
if (body.cohorts && (isAdmin || isModerator)) {
|
||||
article.cohorts = body.cohorts;
|
||||
}
|
||||
if (body.status) {
|
||||
article.status = body.status;
|
||||
if (body.status === "published" && !article.publishedAt) {
|
||||
article.publishedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Add contributor if not already listed
|
||||
const userObjectId = new Types.ObjectId(user.userId);
|
||||
if (!article.contributors.some((c: any) => c.toString() === user.userId)) {
|
||||
article.contributors.push(userObjectId);
|
||||
}
|
||||
|
||||
// Clear lock
|
||||
article.lockedBy = undefined;
|
||||
article.lockedAt = undefined;
|
||||
|
||||
// Save changes
|
||||
try {
|
||||
await article.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
slug: article.slug,
|
||||
message: "Article updated successfully",
|
||||
revision: article.revisions.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating article:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to update article",
|
||||
});
|
||||
}
|
||||
});
|
||||
76
app/server/api/articles/index.get.ts
Normal file
76
app/server/api/articles/index.get.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { checkAccessLevel } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(query.page as string) || 1
|
||||
const limit = parseInt(query.limit as string) || 20
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build filter
|
||||
const filter: any = { status: 'published' }
|
||||
|
||||
// Category filter
|
||||
if (query.category) {
|
||||
filter.category = query.category
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (query.tags) {
|
||||
const tags = (query.tags as string).split(',')
|
||||
filter.tags = { $in: tags }
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (query.search) {
|
||||
filter.$or = [
|
||||
{ title: { $regex: query.search, $options: 'i' } },
|
||||
{ description: { $regex: query.search, $options: 'i' } },
|
||||
{ content: { $regex: query.search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
// Get articles
|
||||
const articles = await Article.find(filter)
|
||||
.populate('author', 'username displayName avatar')
|
||||
.select('-content -revisions -comments')
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
|
||||
// Filter by access level
|
||||
const filteredArticles = []
|
||||
for (const article of articles) {
|
||||
const hasAccess = await checkAccessLevel(
|
||||
event,
|
||||
article.accessLevel,
|
||||
article.cohorts
|
||||
)
|
||||
|
||||
if (hasAccess) {
|
||||
filteredArticles.push(article)
|
||||
} else if (article.accessLevel === 'member' || article.accessLevel === 'cohort') {
|
||||
// Show preview for protected content
|
||||
filteredArticles.push({
|
||||
...article.toObject(),
|
||||
description: article.description.substring(0, 200) + '...',
|
||||
restricted: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await Article.countDocuments(filter)
|
||||
|
||||
return {
|
||||
articles: filteredArticles,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
71
app/server/api/articles/index.post.ts
Normal file
71
app/server/api/articles/index.post.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { requireAuth } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
const user = await requireAuth(event)
|
||||
|
||||
// Check if user can create articles
|
||||
if (!user.permissions.canEdit) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'You do not have permission to create articles'
|
||||
})
|
||||
}
|
||||
|
||||
// Get request body
|
||||
const body = await readBody(event)
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.slug || !body.content) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Title, slug, and content are required'
|
||||
})
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existing = await Article.findOne({ slug: body.slug })
|
||||
if (existing) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'An article with this slug already exists'
|
||||
})
|
||||
}
|
||||
|
||||
// Create article
|
||||
try {
|
||||
const article = await Article.create({
|
||||
slug: body.slug,
|
||||
title: body.title,
|
||||
description: body.description || '',
|
||||
content: body.content,
|
||||
category: body.category || 'general',
|
||||
tags: body.tags || [],
|
||||
accessLevel: body.accessLevel || 'member',
|
||||
cohorts: body.cohorts || [],
|
||||
author: user.userId,
|
||||
status: body.status || 'draft',
|
||||
publishedAt: body.status === 'published' ? new Date() : null,
|
||||
revisions: [{
|
||||
content: body.content,
|
||||
author: user.userId,
|
||||
message: 'Initial creation',
|
||||
createdAt: new Date()
|
||||
}]
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
slug: article.slug,
|
||||
message: 'Article created successfully'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating article:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create article'
|
||||
})
|
||||
}
|
||||
})
|
||||
100
app/server/api/auth/callback.get.ts
Normal file
100
app/server/api/auth/callback.get.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { User } from "../../models/User";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type {
|
||||
OAuthTokenResponse,
|
||||
GhostGuildUserInfo,
|
||||
} from "../../../types/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const query = getQuery(event);
|
||||
|
||||
// Verify state for CSRF protection
|
||||
const storedState = getCookie(event, "oauth_state");
|
||||
if (!storedState || storedState !== query.state) {
|
||||
return sendRedirect(event, "/login?error=invalid_state");
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
deleteCookie(event, "oauth_state");
|
||||
|
||||
// Exchange authorization code for access token
|
||||
try {
|
||||
const tokenResponse = await $fetch<OAuthTokenResponse>(
|
||||
`${config.ghostguildApiUrl}/oauth/token`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
grant_type: "authorization_code",
|
||||
code: query.code,
|
||||
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
|
||||
client_id: config.ghostguildClientId,
|
||||
client_secret: config.ghostguildClientSecret,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Get user information from Ghost Guild
|
||||
const userInfo = await $fetch<GhostGuildUserInfo>(
|
||||
`${config.ghostguildApiUrl}/user/me`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResponse.access_token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Find or create user in our database
|
||||
let user = await User.findOne({ ghostguildId: userInfo.id });
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
ghostguildId: userInfo.id,
|
||||
email: userInfo.email,
|
||||
username: userInfo.username,
|
||||
displayName: userInfo.displayName || userInfo.username,
|
||||
avatar: userInfo.avatar,
|
||||
roles: userInfo.roles || ["member"],
|
||||
permissions: {
|
||||
canEdit: userInfo.roles?.includes("member") || false,
|
||||
canModerate: userInfo.roles?.includes("moderator") || false,
|
||||
canAdmin: userInfo.roles?.includes("admin") || false,
|
||||
},
|
||||
lastLogin: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Update existing user
|
||||
user.displayName = userInfo.displayName || userInfo.username;
|
||||
user.avatar = userInfo.avatar;
|
||||
user.roles = userInfo.roles || ["member"];
|
||||
user.lastLogin = new Date();
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user._id,
|
||||
username: user.username,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
},
|
||||
config.jwtSecret as string,
|
||||
{ expiresIn: "7d" },
|
||||
);
|
||||
|
||||
// Set JWT as httpOnly cookie
|
||||
setCookie(event, "auth-token", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
});
|
||||
|
||||
// Redirect to dashboard or home
|
||||
return sendRedirect(event, "/dashboard");
|
||||
} catch (error) {
|
||||
console.error("OAuth callback error:", error);
|
||||
return sendRedirect(event, "/login?error=authentication_failed");
|
||||
}
|
||||
});
|
||||
28
app/server/api/auth/login.get.ts
Normal file
28
app/server/api/auth/login.get.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Generate state for CSRF protection
|
||||
const state = Math.random().toString(36).substring(7);
|
||||
|
||||
// Store state in session (you'll need to implement session storage)
|
||||
setCookie(event, "oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
});
|
||||
|
||||
// Build OAuth authorization URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: String(config.ghostguildClientId || ""),
|
||||
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
|
||||
response_type: "code",
|
||||
scope: "read:user read:member",
|
||||
state: state,
|
||||
});
|
||||
|
||||
const authUrl = `${config.ghostguildApiUrl}/oauth/authorize?${params}`;
|
||||
|
||||
// Redirect to Ghost Guild OAuth
|
||||
return sendRedirect(event, authUrl);
|
||||
});
|
||||
10
app/server/api/auth/logout.post.ts
Normal file
10
app/server/api/auth/logout.post.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
// Clear the auth token cookie
|
||||
deleteCookie(event, 'auth-token')
|
||||
|
||||
// Return success response
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
}
|
||||
})
|
||||
53
app/server/api/auth/me.get.ts
Normal file
53
app/server/api/auth/me.get.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { User } from "../../models/User";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getCookie(event, "auth-token");
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Unauthorized - No token provided",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify and decode the token
|
||||
const decoded = jwt.verify(token, config.jwtSecret as string) as any;
|
||||
|
||||
// Get fresh user data from database
|
||||
const user = await User.findById(decoded.userId).select("-__v");
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Return user data (without sensitive fields)
|
||||
return {
|
||||
id: user._id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
contributions: user.contributions,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.name === "JsonWebTokenError" ||
|
||||
error.name === "TokenExpiredError"
|
||||
) {
|
||||
deleteCookie(event, "auth-token");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
32
app/server/api/health.get.ts
Normal file
32
app/server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import mongoose from 'mongoose'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const checks = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
mongodb: 'disconnected',
|
||||
memory: process.memoryUsage(),
|
||||
}
|
||||
|
||||
// Check MongoDB connection
|
||||
try {
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
checks.mongodb = 'connected'
|
||||
} else {
|
||||
checks.mongodb = 'disconnected'
|
||||
}
|
||||
} catch (error) {
|
||||
checks.mongodb = 'error'
|
||||
}
|
||||
|
||||
// Return 503 if any critical service is down
|
||||
const isHealthy = checks.mongodb === 'connected'
|
||||
|
||||
if (!isHealthy) {
|
||||
setResponseStatus(event, 503)
|
||||
checks.status = 'unhealthy'
|
||||
}
|
||||
|
||||
return checks
|
||||
})
|
||||
111
app/server/models/Article.ts
Normal file
111
app/server/models/Article.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Schema, model, Document, Types } from 'mongoose'
|
||||
|
||||
export interface IRevision {
|
||||
content: string
|
||||
author: Types.ObjectId
|
||||
message: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface IComment {
|
||||
author: Types.ObjectId
|
||||
content: string
|
||||
parentComment?: Types.ObjectId
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export interface IArticle extends Document {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
content: string // Current content in markdown
|
||||
category: string
|
||||
tags: string[]
|
||||
|
||||
// Access control
|
||||
accessLevel: 'public' | 'member' | 'cohort' | 'admin'
|
||||
cohorts?: string[] // Specific cohorts if accessLevel is 'cohort'
|
||||
|
||||
// Metadata
|
||||
author: Types.ObjectId
|
||||
contributors: Types.ObjectId[]
|
||||
views: number
|
||||
likes: number
|
||||
|
||||
// Editing
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
lockedBy?: Types.ObjectId // If someone is currently editing
|
||||
lockedAt?: Date
|
||||
|
||||
// History
|
||||
revisions: IRevision[]
|
||||
comments: IComment[]
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
publishedAt?: Date
|
||||
}
|
||||
|
||||
const revisionSchema = new Schema<IRevision>({
|
||||
content: { type: String, required: true },
|
||||
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
message: { type: String, required: true },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
const commentSchema = new Schema<IComment>({
|
||||
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
content: { type: String, required: true },
|
||||
parentComment: { type: Schema.Types.ObjectId, ref: 'Comment' },
|
||||
resolved: { type: Boolean, default: false }
|
||||
}, {
|
||||
timestamps: true
|
||||
})
|
||||
|
||||
const articleSchema = new Schema<IArticle>({
|
||||
slug: { type: String, required: true, unique: true },
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
category: { type: String, required: true },
|
||||
tags: [{ type: String }],
|
||||
|
||||
accessLevel: {
|
||||
type: String,
|
||||
enum: ['public', 'member', 'cohort', 'admin'],
|
||||
default: 'member'
|
||||
},
|
||||
cohorts: [{ type: String }],
|
||||
|
||||
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
contributors: [{ type: Schema.Types.ObjectId, ref: 'User' }],
|
||||
views: { type: Number, default: 0 },
|
||||
likes: { type: Number, default: 0 },
|
||||
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'draft'
|
||||
},
|
||||
lockedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
lockedAt: Date,
|
||||
|
||||
revisions: [revisionSchema],
|
||||
comments: [commentSchema],
|
||||
|
||||
publishedAt: Date
|
||||
}, {
|
||||
timestamps: true
|
||||
})
|
||||
|
||||
// Indexes for better query performance
|
||||
articleSchema.index({ slug: 1 })
|
||||
articleSchema.index({ accessLevel: 1, status: 1 })
|
||||
articleSchema.index({ tags: 1 })
|
||||
articleSchema.index({ category: 1 })
|
||||
articleSchema.index({ author: 1 })
|
||||
|
||||
export const Article = model<IArticle>('Article', articleSchema)
|
||||
47
app/server/models/User.ts
Normal file
47
app/server/models/User.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Schema, model, Document } from 'mongoose'
|
||||
|
||||
export interface IUser extends Document {
|
||||
ghostguildId: string // ID from ghostguild-org
|
||||
email: string
|
||||
username: string
|
||||
displayName: string
|
||||
avatar?: string
|
||||
roles: string[] // 'admin', 'moderator', 'member', 'cohort-X'
|
||||
permissions: {
|
||||
canEdit: boolean
|
||||
canModerate: boolean
|
||||
canAdmin: boolean
|
||||
}
|
||||
contributions: {
|
||||
edits: number
|
||||
comments: number
|
||||
articles: number
|
||||
}
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
lastLogin: Date
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUser>({
|
||||
ghostguildId: { type: String, required: true, unique: true },
|
||||
email: { type: String, required: true, unique: true },
|
||||
username: { type: String, required: true, unique: true },
|
||||
displayName: { type: String, required: true },
|
||||
avatar: String,
|
||||
roles: [{ type: String }],
|
||||
permissions: {
|
||||
canEdit: { type: Boolean, default: false },
|
||||
canModerate: { type: Boolean, default: false },
|
||||
canAdmin: { type: Boolean, default: false }
|
||||
},
|
||||
contributions: {
|
||||
edits: { type: Number, default: 0 },
|
||||
comments: { type: Number, default: 0 },
|
||||
articles: { type: Number, default: 0 }
|
||||
},
|
||||
lastLogin: { type: Date, default: Date.now }
|
||||
}, {
|
||||
timestamps: true
|
||||
})
|
||||
|
||||
export const User = model<IUser>('User', userSchema)
|
||||
20
app/server/plugins/mongodb.ts
Normal file
20
app/server/plugins/mongodb.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import mongoose from "mongoose";
|
||||
|
||||
export default defineNitroPlugin(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
try {
|
||||
await mongoose.connect(config.mongodbUri as string);
|
||||
console.log("✓ MongoDB connected successfully");
|
||||
} catch (error) {
|
||||
console.error("✗ MongoDB connection error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => {
|
||||
await mongoose.connection.close();
|
||||
console.log("MongoDB connection closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
91
app/server/plugins/wikilink-transform.ts
Normal file
91
app/server/plugins/wikilink-transform.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Wikilink Transformation Plugin
|
||||
*
|
||||
* Transforms Obsidian wikilink syntax to standard markdown links at build time.
|
||||
* Handles:
|
||||
* - [[Page Title]] → [Page Title](/articles/page-title)
|
||||
* - [[Page|Display Text]] → [Display Text](/articles/page)
|
||||
* - ![[image.jpg]] → 
|
||||
* - ![[image.jpg|alt text]] → 
|
||||
*
|
||||
* Runs during content:file:beforeParse hook for early transformation
|
||||
*/
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('content:file:beforeParse' as any, (file: any) => {
|
||||
// Only process markdown files in articles collection
|
||||
if (file._id && file._id.endsWith('.md') && file._id.includes('/articles/')) {
|
||||
if (file.body) {
|
||||
file.body = transformWikilinks(file.body);
|
||||
file.body = transformImageEmbeds(file.body);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Transform Obsidian wikilinks to markdown links
|
||||
* Patterns:
|
||||
* - [[P0 - Page Title]]
|
||||
* - [[Page Title|Display Text]]
|
||||
* - [[P1 - Long Title|Short]]
|
||||
*/
|
||||
function transformWikilinks(content: string): string {
|
||||
// Pattern: [[P0/P1/P2 - Page Title]] or [[Page Title]] with optional |Display Text
|
||||
const wikilinkPattern = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
||||
|
||||
return content.replace(wikilinkPattern, (match, pageName, displayText) => {
|
||||
// Clean the page name: strip P0/P1/P2 prefix
|
||||
const cleanName = pageName.replace(/^P[012]\s*-\s*/i, '').trim();
|
||||
|
||||
// Generate slug from clean name
|
||||
const slug = titleToSlug(cleanName);
|
||||
|
||||
// Use display text if provided, otherwise use clean name
|
||||
const linkText = displayText ? displayText.trim() : cleanName;
|
||||
|
||||
// Generate markdown link with /articles/ prefix
|
||||
return `[${linkText}](/articles/${slug})`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Obsidian image embeds to markdown image syntax
|
||||
* Patterns:
|
||||
* - ![[image.jpg]]
|
||||
* - ![[image.jpg|alt text]]
|
||||
*/
|
||||
function transformImageEmbeds(content: string): string {
|
||||
// Pattern: ![[filename]] or ![[filename|alt text]]
|
||||
const imagePattern = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
||||
|
||||
return content.replace(imagePattern, (match, filename, altText) => {
|
||||
// Use alt text if provided, otherwise use filename (without extension)
|
||||
const alt = altText?.trim() || filename.replace(/\.(jpg|jpeg|png|gif|svg|webp)$/i, '');
|
||||
|
||||
// Generate markdown image syntax with /img/ prefix
|
||||
return ``;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page title to a URL slug
|
||||
* Rules:
|
||||
* 1. Convert to lowercase
|
||||
* 2. Remove quotation marks
|
||||
* 3. Replace & with "and"
|
||||
* 4. Remove other special characters (keep alphanumeric and hyphens)
|
||||
* 5. Replace spaces with hyphens
|
||||
* 6. Collapse multiple hyphens to single hyphen
|
||||
* 7. Trim hyphens from start/end
|
||||
*/
|
||||
function titleToSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, '') // Remove quotes
|
||||
.replace(/&/g, 'and') // & → and
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Spaces → hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ''); // Trim hyphens from edges
|
||||
}
|
||||
3
app/server/tsconfig.json
Normal file
3
app/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
12
app/server/types.d.ts
vendored
Normal file
12
app/server/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
declare module "nitropack" {
|
||||
interface NitroRuntimeConfig {
|
||||
mongodbUri: string;
|
||||
jwtSecret: string;
|
||||
ghostguildApiUrl: string;
|
||||
ghostguildApiKey: string;
|
||||
ghostguildClientId: string;
|
||||
ghostguildClientSecret: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
100
app/server/utils/auth.ts
Normal file
100
app/server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import type { H3Event } from "h3";
|
||||
import { User } from "../models/User";
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
canModerate: boolean;
|
||||
canAdmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAuth(event: H3Event): Promise<AuthUser | null> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getCookie(event, "auth-token");
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret as string) as AuthUser;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(event: H3Event): Promise<AuthUser> {
|
||||
const user = await verifyAuth(event);
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireRole(
|
||||
event: H3Event,
|
||||
requiredRoles: string[],
|
||||
): Promise<AuthUser> {
|
||||
const user = await requireAuth(event);
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||
|
||||
if (!hasRole) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Insufficient permissions",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function checkAccessLevel(
|
||||
event: H3Event,
|
||||
accessLevel: "public" | "member" | "cohort" | "admin",
|
||||
cohorts?: string[],
|
||||
): Promise<boolean> {
|
||||
// Public content is always accessible
|
||||
if (accessLevel === "public") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const user = await verifyAuth(event);
|
||||
|
||||
// No user = no access to protected content
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check access levels
|
||||
switch (accessLevel) {
|
||||
case "member":
|
||||
// Any authenticated user has member access
|
||||
return true;
|
||||
|
||||
case "cohort":
|
||||
// Check if user belongs to required cohorts
|
||||
if (!cohorts || cohorts.length === 0) {
|
||||
return true; // No specific cohort required
|
||||
}
|
||||
return cohorts.some((cohort) => user.roles.includes(`cohort-${cohort}`));
|
||||
|
||||
case "admin":
|
||||
// Only admins have admin access
|
||||
return user.permissions.canAdmin || user.roles.includes("admin");
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
46
app/types/auth.ts
Normal file
46
app/types/auth.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
roles: string[];
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
canModerate: boolean;
|
||||
canAdmin: boolean;
|
||||
};
|
||||
contributions: {
|
||||
articles: number;
|
||||
edits: number;
|
||||
comments: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
canModerate: boolean;
|
||||
canAdmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface GhostGuildUserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue