Compare commits
No commits in common. "c836df8825fef3e29ffe9b44ee2b01a2ffd70e38" and "a92cb4f19b371d137a524c7a38fbb204ea104453" have entirely different histories.
c836df8825
...
a92cb4f19b
49
.env.example
|
|
@ -1,3 +1,46 @@
|
||||||
# Used by docker-compose.yml for the PostgreSQL service
|
# MongoDB Connection
|
||||||
# Generate with: openssl rand -hex 16
|
MONGODB_URI=mongodb://localhost:27017/wiki-ghostguild
|
||||||
POSTGRES_PASSWORD=CHANGE_ME
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
|
||||||
|
|
||||||
|
# Ghost Guild OAuth Integration
|
||||||
|
GHOSTGUILD_API_URL=https://ghostguild.org/api
|
||||||
|
GHOSTGUILD_API_KEY=your-api-key
|
||||||
|
GHOSTGUILD_CLIENT_ID=your-client-id
|
||||||
|
GHOSTGUILD_CLIENT_SECRET=your-client-secret
|
||||||
|
GHOSTGUILD_REDIRECT_URI=https://wiki.ghostguild.org/api/auth/callback
|
||||||
|
|
||||||
|
# Site Configuration
|
||||||
|
SITE_URL=https://wiki.ghostguild.org
|
||||||
|
SITE_NAME=Ghost Guild Knowledge Commons
|
||||||
|
SITE_DESCRIPTION=A wiki for ghosts! 👻
|
||||||
|
|
||||||
|
# Forgejo Integration
|
||||||
|
FORGEJO_URL=https://git.ghostguild.org
|
||||||
|
FORGEJO_TOKEN=your-forgejo-token
|
||||||
|
FORGEJO_REPO=ghostguild/wiki-content
|
||||||
|
|
||||||
|
# Search (Meilisearch)
|
||||||
|
MEILISEARCH_URL=http://localhost:7700
|
||||||
|
MEILISEARCH_KEY=your-master-key
|
||||||
|
|
||||||
|
# Email (for notifications)
|
||||||
|
EMAIL_FROM=wiki@ghostguild.org
|
||||||
|
EMAIL_SMTP_HOST=smtp.example.com
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_SMTP_USER=your-smtp-user
|
||||||
|
EMAIL_SMTP_PASS=your-smtp-password
|
||||||
|
|
||||||
|
# Analytics (Optional)
|
||||||
|
PLAUSIBLE_DOMAIN=wiki.ghostguild.org
|
||||||
|
PLAUSIBLE_API_KEY=your-plausible-key
|
||||||
|
|
||||||
|
# Error Tracking (Optional)
|
||||||
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
SENTRY_ENVIRONMENT=production
|
||||||
|
|
||||||
|
# Development
|
||||||
|
NODE_ENV=development
|
||||||
|
DEBUG=false
|
||||||
|
|
|
||||||
15
.gitignore
vendored
|
|
@ -1,5 +1,10 @@
|
||||||
# Outline secrets
|
# Nuxt dev/build outputs
|
||||||
outline.env
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
@ -28,6 +33,6 @@ conflict-files-obsidian-git.md
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Project docs (local only)
|
/*.md
|
||||||
/docs
|
/migration/*
|
||||||
/migration
|
MIGRATION_STATUS.txt
|
||||||
|
|
|
||||||
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ignore-scripts=true
|
||||||
|
optional=false
|
||||||
31
Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --yes --omit=dev
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate static site
|
||||||
|
RUN npm run generate
|
||||||
|
|
||||||
|
# Production stage - static nginx server
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy generated static files from builder
|
||||||
|
COPY --from=builder /app/.output/public /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
65
app.config.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
primary: 'blue',
|
||||||
|
gray: 'neutral',
|
||||||
|
|
||||||
|
// Configure prose components
|
||||||
|
content: {
|
||||||
|
prose: {
|
||||||
|
// Base prose styling
|
||||||
|
prose: {
|
||||||
|
base: {
|
||||||
|
fontFamily: "'Crimson Text', Georgia, serif",
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
lineHeight: '1.75',
|
||||||
|
maxWidth: 'none',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Paragraph styling
|
||||||
|
p: {
|
||||||
|
base: 'font-serif text-lg leading-relaxed mt-5 mb-5',
|
||||||
|
},
|
||||||
|
// Heading styles
|
||||||
|
h1: {
|
||||||
|
base: 'font-serif font-bold text-4xl mt-8 mb-4',
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
base: 'font-serif font-semibold text-3xl mt-8 mb-4',
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
base: 'font-serif font-semibold text-2xl mt-6 mb-3',
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
base: 'font-serif font-semibold text-xl mt-6 mb-3',
|
||||||
|
},
|
||||||
|
// List styling
|
||||||
|
ul: {
|
||||||
|
base: 'list-disc pl-6 my-5 space-y-2',
|
||||||
|
},
|
||||||
|
ol: {
|
||||||
|
base: 'list-decimal pl-6 my-5 space-y-2',
|
||||||
|
},
|
||||||
|
li: {
|
||||||
|
base: 'font-serif text-lg leading-relaxed',
|
||||||
|
},
|
||||||
|
// Link styling
|
||||||
|
a: {
|
||||||
|
base: 'text-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300',
|
||||||
|
},
|
||||||
|
// Blockquote styling
|
||||||
|
blockquote: {
|
||||||
|
base: 'border-l-4 border-gray-300 dark:border-gray-700 pl-4 my-5 italic font-serif',
|
||||||
|
},
|
||||||
|
// Code styling
|
||||||
|
code: {
|
||||||
|
inline: {
|
||||||
|
base: 'bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-sm font-mono',
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
base: 'bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto my-5',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
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>
|
||||||
15
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&display=swap");
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/components/mdc/Alert.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
:color="color"
|
||||||
|
:variant="variant"
|
||||||
|
:icon="icon"
|
||||||
|
:title="title"
|
||||||
|
:description="description"
|
||||||
|
class="my-4 font-serif"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.default" #description>
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none font-serif">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
type AlertColor =
|
||||||
|
| "error"
|
||||||
|
| "info"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "neutral";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type?: "info" | "warning" | "error" | "success" | "primary";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
variant?: "solid" | "outline" | "soft" | "subtle";
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: "primary",
|
||||||
|
variant: "soft",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map type to Nuxt UI 3 color
|
||||||
|
const color = computed<AlertColor>(() => {
|
||||||
|
const colorMap: Record<string, AlertColor> = {
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
error: "error",
|
||||||
|
success: "success",
|
||||||
|
primary: "primary",
|
||||||
|
};
|
||||||
|
return colorMap[props.type || "primary"] || "primary";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
20
app/components/mdc/List.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template>
|
||||||
|
<div class="my-4">
|
||||||
|
<ul v-if="!ordered" class="list-disc list-inside space-y-2 prose prose-lg dark:prose-invert">
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
<ol v-else class="list-decimal list-inside space-y-2 prose prose-lg dark:prose-invert">
|
||||||
|
<slot />
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
ordered?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
ordered: false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
64
app/layouts/default.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Ghost Guild Wiki
|
||||||
|
</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>
|
||||||
152
app/pages/articles/[slug].vue
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<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 -->
|
||||||
|
<ContentRenderer
|
||||||
|
:value="article"
|
||||||
|
class="prose prose-lg prose-gray dark:prose-invert max-w-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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;
|
||||||
|
|
||||||
|
// Fetch article from Nuxt Content
|
||||||
|
const { data: article, pending } = await useAsyncData(
|
||||||
|
`article-${slug}`,
|
||||||
|
async () => {
|
||||||
|
// Query for the specific article by stem (filename without extension)
|
||||||
|
const articles = await queryCollection("articles").all();
|
||||||
|
return articles.find((a) => a.stem === 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>
|
||||||
154
app/pages/articles/index.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<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 font-serif text-lg leading-relaxed"
|
||||||
|
>
|
||||||
|
{{ 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 "";
|
||||||
|
// stem is the filename without extension
|
||||||
|
return article.stem || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArticlePath = (article) => {
|
||||||
|
if (!article) return "/articles";
|
||||||
|
const slug = getArticleSlug(article);
|
||||||
|
return `/articles/${slug}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
|
@ -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>
|
||||||
101
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
A wiki for ghosts! 👻
|
||||||
|
</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">View all articles</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4"></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">Ghost Guild</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4"></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.stem"
|
||||||
|
class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/articles/${article.stem}`"
|
||||||
|
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
72
app/server/api/articles/index.post.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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 || "public",
|
||||||
|
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
})
|
||||||
117
app/server/models/Article.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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: "public",
|
||||||
|
},
|
||||||
|
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
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
|
|
@ -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
|
|
@ -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[];
|
||||||
|
}
|
||||||
26
content.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineContentConfig, defineCollection } from "@nuxt/content";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
articles: defineCollection({
|
||||||
|
type: "page",
|
||||||
|
source: {
|
||||||
|
include: "articles/*.md",
|
||||||
|
prefix: "/",
|
||||||
|
},
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
contributors: z.array(z.string()).optional(),
|
||||||
|
published: z.boolean().default(true),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
accessLevel: z.string().optional(),
|
||||||
|
publishedAt: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"syncMethod": "merge",
|
"syncMethod": "merge",
|
||||||
"customMessageOnAutoBackup": false,
|
"customMessageOnAutoBackup": false,
|
||||||
"autoBackupAfterFileChange": false,
|
"autoBackupAfterFileChange": false,
|
||||||
"treeStructure": true,
|
"treeStructure": false,
|
||||||
"refreshSourceControl": true,
|
"refreshSourceControl": true,
|
||||||
"basePath": "",
|
"basePath": "",
|
||||||
"differentIntervalCommitAndPush": false,
|
"differentIntervalCommitAndPush": false,
|
||||||
|
|
@ -55,9 +55,7 @@
|
||||||
},
|
},
|
||||||
"textColorCss": "var(--text-muted)",
|
"textColorCss": "var(--text-muted)",
|
||||||
"ignoreWhitespace": false,
|
"ignoreWhitespace": false,
|
||||||
"gutterSpacingFallbackLength": 5,
|
"gutterSpacingFallbackLength": 5
|
||||||
"lastShownAuthorDisplay": "initials",
|
|
||||||
"lastShownDateTimeFormatOptions": "date"
|
|
||||||
},
|
},
|
||||||
"autoCommit": false,
|
"autoCommit": false,
|
||||||
"autoSaveOnFocusChange": false,
|
"autoSaveOnFocusChange": false,
|
||||||
|
|
|
||||||
29
content/articles/.obsidian/workspace.json
vendored
|
|
@ -4,21 +4,17 @@
|
||||||
"type": "split",
|
"type": "split",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "77a37c5d6b29181c",
|
"id": "2f6eb446821d6623",
|
||||||
"type": "tabs",
|
"type": "tabs",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "53add52357bc7f3f",
|
"id": "e9fb0ab9519305e2",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "split-diff-view",
|
"type": "graph",
|
||||||
"state": {
|
"state": {},
|
||||||
"aFile": "content/articles/.obsidian/workspace.json",
|
"icon": "lucide-git-fork",
|
||||||
"bFile": "content/articles/.obsidian/workspace.json",
|
"title": "Graph view"
|
||||||
"aRef": ""
|
|
||||||
},
|
|
||||||
"icon": "diff",
|
|
||||||
"title": "Diff: workspace.json"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -156,12 +152,13 @@
|
||||||
"state": {
|
"state": {
|
||||||
"type": "outline",
|
"type": "outline",
|
||||||
"state": {
|
"state": {
|
||||||
|
"file": "schedule.md",
|
||||||
"followCursor": false,
|
"followCursor": false,
|
||||||
"showSearch": false,
|
"showSearch": false,
|
||||||
"searchQuery": ""
|
"searchQuery": ""
|
||||||
},
|
},
|
||||||
"icon": "lucide-list",
|
"icon": "lucide-list",
|
||||||
"title": "Outline"
|
"title": "Outline of schedule"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -193,12 +190,8 @@
|
||||||
"obsidian-git:Open Git source control": false
|
"obsidian-git:Open Git source control": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active": "53add52357bc7f3f",
|
"active": "dde602e7b0ba07df",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
"decisions-and-conflict.md",
|
|
||||||
"publisher-contract-review.md",
|
|
||||||
"actionable-steam-metrics.md",
|
|
||||||
"co-op-structure.md",
|
|
||||||
"schedule.md",
|
"schedule.md",
|
||||||
"business-planning.md.tmp.30034.1763236506685",
|
"business-planning.md.tmp.30034.1763236506685",
|
||||||
"business-planning.md.tmp.30034.1763236492149",
|
"business-planning.md.tmp.30034.1763236492149",
|
||||||
|
|
@ -213,11 +206,13 @@
|
||||||
"stages-of-coop-development.md",
|
"stages-of-coop-development.md",
|
||||||
"structures-for-impact.md",
|
"structures-for-impact.md",
|
||||||
"tiktok-meeting-notes.md",
|
"tiktok-meeting-notes.md",
|
||||||
|
"actionable-steam-metrics.md",
|
||||||
"canada-council-funding.md",
|
"canada-council-funding.md",
|
||||||
"business-planning.md",
|
"business-planning.md",
|
||||||
"telling-your-story.md",
|
"telling-your-story.md",
|
||||||
"self-assessment.md",
|
"self-assessment.md",
|
||||||
"results-flow.md",
|
"results-flow.md",
|
||||||
|
"publisher-contract-review.md",
|
||||||
"process-development.md",
|
"process-development.md",
|
||||||
"pitching-to-publishers.md",
|
"pitching-to-publishers.md",
|
||||||
"market-analysis.md",
|
"market-analysis.md",
|
||||||
|
|
@ -225,6 +220,8 @@
|
||||||
"game-discovery-toolkit.md",
|
"game-discovery-toolkit.md",
|
||||||
"financial-modelling.md",
|
"financial-modelling.md",
|
||||||
"expo-tips.md",
|
"expo-tips.md",
|
||||||
|
"decisions-and-conflict.md",
|
||||||
|
"co-op-structure.md",
|
||||||
"cmf-quick-tips.md",
|
"cmf-quick-tips.md",
|
||||||
"actionable-values.md",
|
"actionable-values.md",
|
||||||
"accessibility-guide-disability-in-game-development-studios.md",
|
"accessibility-guide-disability-in-game-development-studios.md",
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,64 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
outline:
|
# MongoDB Database
|
||||||
image: docker.getoutline.com/outlinewiki/outline:latest
|
mongodb:
|
||||||
container_name: outline
|
image: mongo:7
|
||||||
|
container_name: wiki-mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3100:3000"
|
- "27017:27017"
|
||||||
env_file: ./outline.env
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- outline-storage:/var/lib/outline/data
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: outline-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: outline
|
MONGO_INITDB_ROOT_USERNAME: admin
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
MONGO_INITDB_ROOT_PASSWORD: changeme
|
||||||
POSTGRES_DB: outline
|
MONGO_INITDB_DATABASE: wiki-ghostguild
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- mongodb_data:/data/db
|
||||||
command:
|
- ./docker/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
|
||||||
- "postgres"
|
networks:
|
||||||
- "-c"
|
- wiki-network
|
||||||
- "shared_buffers=128MB"
|
|
||||||
- "-c"
|
|
||||||
- "max_connections=20"
|
|
||||||
- "-c"
|
|
||||||
- "work_mem=4MB"
|
|
||||||
- "-c"
|
|
||||||
- "maintenance_work_mem=64MB"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U outline"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
redis:
|
# Wiki Application (for local development)
|
||||||
image: redis:7-alpine
|
# Uncomment to run the app in Docker
|
||||||
container_name: outline-redis
|
# wiki:
|
||||||
|
# build: .
|
||||||
|
# container_name: wiki-app
|
||||||
|
# restart: unless-stopped
|
||||||
|
# ports:
|
||||||
|
# - "3000:3000"
|
||||||
|
# environment:
|
||||||
|
# MONGODB_URI: mongodb://admin:changeme@mongodb:27017/wiki-ghostguild?authSource=admin
|
||||||
|
# NODE_ENV: development
|
||||||
|
# depends_on:
|
||||||
|
# - mongodb
|
||||||
|
# networks:
|
||||||
|
# - wiki-network
|
||||||
|
# volumes:
|
||||||
|
# - ./app:/app/app
|
||||||
|
# - ./server:/app/server
|
||||||
|
# - ./content:/app/content
|
||||||
|
|
||||||
|
# Optional: Mongo Express for database management
|
||||||
|
mongo-express:
|
||||||
|
image: mongo-express:latest
|
||||||
|
container_name: wiki-mongo-express
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: >
|
ports:
|
||||||
redis-server
|
- "8081:8081"
|
||||||
--maxmemory 64mb
|
environment:
|
||||||
--maxmemory-policy allkeys-lru
|
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
|
||||||
volumes:
|
ME_CONFIG_MONGODB_ADMINPASSWORD: changeme
|
||||||
- redis-data:/data
|
ME_CONFIG_MONGODB_URL: mongodb://admin:changeme@mongodb:27017/
|
||||||
healthcheck:
|
ME_CONFIG_BASICAUTH: false
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
depends_on:
|
||||||
interval: 10s
|
- mongodb
|
||||||
timeout: 5s
|
networks:
|
||||||
retries: 5
|
- wiki-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
outline-storage:
|
mongodb_data:
|
||||||
postgres-data:
|
driver: local
|
||||||
redis-data:
|
|
||||||
|
networks:
|
||||||
|
wiki-network:
|
||||||
|
driver: bridge
|
||||||
67
dokploy.yaml
|
|
@ -1,31 +1,44 @@
|
||||||
# Dokploy Configuration for Wiki.GhostGuild.org
|
# DokPloy Configuration for Wiki.GhostGuild.org
|
||||||
# Outline wiki deployed via docker-compose
|
|
||||||
name: wiki-ghostguild
|
name: wiki-ghostguild
|
||||||
type: compose
|
type: nixpacks
|
||||||
|
|
||||||
# Build Configuration
|
# Build Configuration
|
||||||
build:
|
build:
|
||||||
composeFile: docker-compose.yml
|
dockerfile: null # Using Nixpacks auto-detection
|
||||||
|
context: .
|
||||||
|
|
||||||
# Secrets (configured in Dokploy UI)
|
# Environment Configuration
|
||||||
# These should be set in the Dokploy interface, not in this file:
|
env:
|
||||||
# - POSTGRES_PASSWORD (used by docker-compose for the postgres service)
|
NODE_ENV: production
|
||||||
# - All Outline env vars are in outline.env on the server
|
NITRO_PRESET: node-server
|
||||||
|
|
||||||
|
# Secrets (configured in DokPloy UI)
|
||||||
|
# These should be set in DokPloy interface, not in this file:
|
||||||
|
# - MONGODB_URI
|
||||||
|
# - JWT_SECRET
|
||||||
|
# - JWT_REFRESH_SECRET
|
||||||
|
# - GHOSTGUILD_CLIENT_ID
|
||||||
|
# - GHOSTGUILD_CLIENT_SECRET
|
||||||
|
# - GHOSTGUILD_API_KEY
|
||||||
|
|
||||||
|
# Port Configuration
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
|
||||||
# Health Check
|
# Health Check
|
||||||
healthcheck:
|
healthcheck:
|
||||||
path: /api/health
|
path: /api/health
|
||||||
interval: 30
|
interval: 30
|
||||||
timeout: 10
|
timeout: 10
|
||||||
retries: 5
|
retries: 3
|
||||||
|
|
||||||
# Resource Limits (Outline + Postgres + Redis combined)
|
# Resource Limits
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 3GB
|
|
||||||
cpu: 2
|
|
||||||
requests:
|
|
||||||
memory: 1GB
|
memory: 1GB
|
||||||
|
cpu: 1
|
||||||
|
requests:
|
||||||
|
memory: 512MB
|
||||||
cpu: 0.5
|
cpu: 0.5
|
||||||
|
|
||||||
# Domains
|
# Domains
|
||||||
|
|
@ -45,8 +58,34 @@ deploy:
|
||||||
maxSurge: 1
|
maxSurge: 1
|
||||||
maxUnavailable: 0
|
maxUnavailable: 0
|
||||||
|
|
||||||
|
# Volume Mounts (for persistent data)
|
||||||
|
volumes:
|
||||||
|
- name: content
|
||||||
|
path: /app/content
|
||||||
|
size: 5GB
|
||||||
|
- name: uploads
|
||||||
|
path: /app/public/uploads
|
||||||
|
size: 10GB
|
||||||
|
|
||||||
|
# Commands
|
||||||
|
scripts:
|
||||||
|
prebuild: |
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
npm ci
|
||||||
|
build: |
|
||||||
|
echo "Building application..."
|
||||||
|
npm run build
|
||||||
|
start: |
|
||||||
|
echo "Starting production server..."
|
||||||
|
node .output/server/index.mjs
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
monitoring:
|
||||||
|
enabled: true
|
||||||
|
metrics_path: /metrics
|
||||||
|
|
||||||
# Backup Configuration
|
# Backup Configuration
|
||||||
backup:
|
backup:
|
||||||
enabled: true
|
enabled: true
|
||||||
schedule: "0 2 * * *" # Daily at 2 AM
|
schedule: "0 2 * * *" # Daily at 2 AM
|
||||||
retention: 14 # Keep 14 days of backups
|
retention: 30 # Keep 30 days of backups
|
||||||
62
nginx.conf
|
|
@ -22,59 +22,45 @@ http {
|
||||||
tcp_nodelay on;
|
tcp_nodelay on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
client_max_body_size 50M;
|
client_max_body_size 20M;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
application/javascript application/json
|
application/x-javascript application/xml+rss
|
||||||
application/xml+rss image/svg+xml;
|
application/javascript application/json;
|
||||||
|
|
||||||
upstream outline {
|
|
||||||
server 127.0.0.1:3100;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
server_name wiki.ghostguild.org;
|
server_name _;
|
||||||
|
|
||||||
# Serve custom theme files
|
root /usr/share/nginx/html;
|
||||||
location /custom/ {
|
index index.html;
|
||||||
alias /opt/ghost-guild-wiki-theme/;
|
|
||||||
expires 1h;
|
|
||||||
add_header Cache-Control "public";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check
|
# Health check endpoint
|
||||||
location = /api/health {
|
location /api/health {
|
||||||
proxy_pass http://outline;
|
|
||||||
access_log off;
|
access_log off;
|
||||||
|
add_header Content-Type application/json;
|
||||||
|
return 200 '{"status":"ok"}';
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reverse proxy to Outline with CSS injection
|
# Static files
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML files - no cache
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - route all unmatched requests to index.html
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://outline;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
# Standard proxy headers
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Disable encoding so sub_filter can process the response
|
|
||||||
proxy_set_header Accept-Encoding "";
|
|
||||||
|
|
||||||
# WebSocket support (required for real-time collaboration)
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
# Inject custom CSS before </head>
|
|
||||||
sub_filter '</head>' '<link rel="stylesheet" href="/custom/ghost-guild.css" /></head>';
|
|
||||||
sub_filter_once on;
|
|
||||||
sub_filter_types text/html;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
nixpacks.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[phases.install]
|
||||||
|
commands = ["npm install --ignore-scripts", "npm rebuild"]
|
||||||
|
|
||||||
|
[phases.build]
|
||||||
|
commands = ["npm run postinstall", "npm run build"]
|
||||||
|
|
||||||
|
[variables]
|
||||||
|
OXCPARSER_SKIP_NATIVE_BINDING="1"
|
||||||
73
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
future: {
|
||||||
|
compatibilityVersion: 4,
|
||||||
|
},
|
||||||
|
srcDir: "app/",
|
||||||
|
compatibilityDate: "2024-11-01",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: ["@nuxt/ui", "@nuxt/content", "nuxt-auth-utils", "@vueuse/nuxt"],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
// Private runtime config (server-side only)
|
||||||
|
mongodbUri:
|
||||||
|
process.env.MONGODB_URI || "mongodb://localhost:27017/wiki-ghostguild",
|
||||||
|
jwtSecret: process.env.JWT_SECRET || "your-secret-key-change-in-production",
|
||||||
|
ghostguildApiUrl:
|
||||||
|
process.env.GHOSTGUILD_API_URL || "https://ghostguild.org/api",
|
||||||
|
ghostguildApiKey: process.env.GHOSTGUILD_API_KEY || "",
|
||||||
|
ghostguildClientId: process.env.GHOSTGUILD_CLIENT_ID || "",
|
||||||
|
ghostguildClientSecret: process.env.GHOSTGUILD_CLIENT_SECRET || "",
|
||||||
|
|
||||||
|
// Public runtime config (client-side)
|
||||||
|
public: {
|
||||||
|
siteUrl: process.env.SITE_URL || "https://wiki.ghostguild.org",
|
||||||
|
siteName: "Ghost Guild Knowledge Commons",
|
||||||
|
siteDescription: "A wiki for ghosts! 👻",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
wasm: true,
|
||||||
|
},
|
||||||
|
prerender: {
|
||||||
|
routes: ["/", "/articles", "/articles/new"],
|
||||||
|
failOnError: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
typeCheck: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.googleapis.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossorigin: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&display=swap",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
minify: "esbuild",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# =============================================================================
|
|
||||||
# Outline Wiki — Environment Configuration
|
|
||||||
# Copy to outline.env and fill in all values before running docker compose up.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Core secrets
|
|
||||||
# ---------------------
|
|
||||||
# Generate each with: openssl rand -hex 32
|
|
||||||
SECRET_KEY=
|
|
||||||
UTILS_SECRET=
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------
|
|
||||||
DATABASE_URL=postgres://outline:CHANGE_ME@postgres:5432/outline
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Application
|
|
||||||
# ---------------------
|
|
||||||
URL=https://wiki.ghostguild.org
|
|
||||||
PORT=3000
|
|
||||||
FORCE_HTTPS=true
|
|
||||||
|
|
||||||
# File storage — local (stored in Docker volume)
|
|
||||||
FILE_STORAGE=local
|
|
||||||
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# OIDC Authentication (Ghost Guild SSO)
|
|
||||||
# ---------------------
|
|
||||||
OIDC_CLIENT_ID=outline-wiki
|
|
||||||
OIDC_CLIENT_SECRET=CHANGE_ME
|
|
||||||
OIDC_AUTH_URI=https://ghostguild.org/oidc/auth
|
|
||||||
OIDC_TOKEN_URI=https://ghostguild.org/oidc/token
|
|
||||||
OIDC_USERINFO_URI=https://ghostguild.org/oidc/me
|
|
||||||
OIDC_LOGOUT_URI=https://ghostguild.org/oidc/session/end
|
|
||||||
OIDC_DISPLAY_NAME=Ghost Guild
|
|
||||||
OIDC_SCOPES=openid profile email
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Email (via Resend SMTP)
|
|
||||||
# ---------------------
|
|
||||||
SMTP_HOST=smtp.resend.com
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_SECURE=true
|
|
||||||
SMTP_USERNAME=resend
|
|
||||||
SMTP_PASSWORD=CHANGE_ME
|
|
||||||
SMTP_FROM_EMAIL=wiki@ghostguild.org
|
|
||||||
SMTP_REPLY_EMAIL=support@ghostguild.org
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Optional
|
|
||||||
# ---------------------
|
|
||||||
# PGSSLMODE=disable
|
|
||||||
# LOG_LEVEL=info
|
|
||||||
# DEFAULT_LANGUAGE=en_US
|
|
||||||
# RATE_LIMITER_ENABLED=true
|
|
||||||
# RATE_LIMITER_REQUESTS=1000
|
|
||||||
# RATE_LIMITER_DURATION_WINDOW=60
|
|
||||||
15050
package-lock.json
generated
Normal file
38
package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "wiki-ghostguild",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt generate",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"start": "node .output/server/index.mjs",
|
||||||
|
"postinstall": "nuxt prepare || true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/content": "^3.7.1",
|
||||||
|
"@nuxt/ui": "^3.3.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/marked": "^5.0.2",
|
||||||
|
"@types/mongoose": "^5.11.96",
|
||||||
|
"@vueuse/nuxt": "^14.0.0",
|
||||||
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"marked": "^17.0.0",
|
||||||
|
"mongoose": "^8.19.3",
|
||||||
|
"nuxt": "^4.2.1",
|
||||||
|
"nuxt-auth-utils": "^0.5.25",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vue-tsc": "^3.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
11
public/img/.gitkeep
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Image directory
|
||||||
|
# Images are stored here and referenced from articles via /img/filename
|
||||||
|
|
||||||
|
# Users can add images by:
|
||||||
|
# 1. Opening /content/articles/ as Obsidian vault
|
||||||
|
# 2. Pasting images in articles (auto-saves here)
|
||||||
|
# 3. Or manually adding images to this directory
|
||||||
|
|
||||||
|
# Naming convention:
|
||||||
|
# - Use lowercase, hyphens (not spaces)
|
||||||
|
# - Example: article-diagram.jpg
|
||||||
BIN
public/img/MoFo_AI_Theory_of_Change_(ToC)_–_Landscape_Design.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
42
public/img/Weird_Ghosts_Logo-transparent copy.svg
Normal file
|
After Width: | Height: | Size: 61 KiB |
42
public/img/Weird_Ghosts_Logo-transparent.svg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/img/blended-returns.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
public/img/cartesian-mapping.jpg
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
public/img/clc_theory-change-2020_EN.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
public/img/co-op-structure-2.jpg
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
public/img/co-op-structure.jpg
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
public/img/coops.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/img/creating-step-1.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
public/img/creating-step-2.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/img/creating-step-3.jpg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/img/creating-step-4.jpg
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
public/img/doc-flow-diagram.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
public/img/gamma-space-value-flow-example.jpg
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
public/img/gamma-space-value-flow.jpg
Normal file
|
After Width: | Height: | Size: 640 KiB |
BIN
public/img/gamma-space-values.jpg
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
public/img/image1.png
Normal file
|
After Width: | Height: | Size: 928 KiB |
BIN
public/img/image10.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
public/img/image2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/img/image3.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
public/img/image4.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/img/image5.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/img/image6.png
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
public/img/image8.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/img/image9.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/img/imf-airtable.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
public/img/imf-spreadsheet.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
public/img/imf.png
Normal file
|
After Width: | Height: | Size: 540 KiB |
BIN
public/img/layers-of-effect.jpg
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
public/img/loe-2.jpg
Normal file
|
After Width: | Height: | Size: 591 KiB |
BIN
public/img/logo copy.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/img/logo.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/img/market-analysis.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
public/img/node-based.jpg
Normal file
|
After Width: | Height: | Size: 603 KiB |
BIN
public/img/tki-scale.jpg
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
public/img/weird_explosion copy.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/img/weird_explosion.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/img/weird_ghosts_results_flow.jpg
Normal file
|
After Width: | Height: | Size: 308 KiB |
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
39
scripts/audit-images.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Audit script to identify missing image references
|
||||||
|
|
||||||
|
echo "=== Image Reference Audit ==="
|
||||||
|
echo ""
|
||||||
|
echo "Finding all image references in articles..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
REFS=$(grep -rh '!\[' content/articles/ 2>/dev/null | grep -oE '\(/img/[^)]+\)' | sed 's/(//' | sed 's/)//' | sort -u)
|
||||||
|
|
||||||
|
echo "Total image references found: $(echo "$REFS" | wc -l)"
|
||||||
|
echo ""
|
||||||
|
echo "Checking which exist in /public/img/:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
MISSING_COUNT=0
|
||||||
|
EXISTING_COUNT=0
|
||||||
|
|
||||||
|
while read -r ref; do
|
||||||
|
if [ -z "$ref" ]; then continue; fi
|
||||||
|
|
||||||
|
filename=$(basename "$ref")
|
||||||
|
fullpath="/Users/jennie/Sites/wiki-ghostguild${ref}"
|
||||||
|
|
||||||
|
if [ -f "$fullpath" ]; then
|
||||||
|
echo "✓ $filename"
|
||||||
|
((EXISTING_COUNT++))
|
||||||
|
else
|
||||||
|
echo "✗ MISSING: $filename"
|
||||||
|
((MISSING_COUNT++))
|
||||||
|
fi
|
||||||
|
done <<< "$REFS"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Existing images: $EXISTING_COUNT"
|
||||||
|
echo "Missing images: $MISSING_COUNT"
|
||||||
|
echo ""
|
||||||
|
echo "Missing images need to be added to /public/img/ or updated in article markdown."
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate markdown articles from content/articles/ into Outline wiki.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* OUTLINE_URL=http://localhost:3100 OUTLINE_API_TOKEN=your-token node migrate-content.js
|
|
||||||
*
|
|
||||||
* Requires: npm install (in this directory) to get gray-matter.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import matter from "gray-matter";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
const OUTLINE_URL = process.env.OUTLINE_URL;
|
|
||||||
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
|
|
||||||
const CONTENT_DIR = path.resolve(__dirname, "../content/articles");
|
|
||||||
const RATE_LIMIT_MS = 200;
|
|
||||||
|
|
||||||
if (!OUTLINE_URL || !OUTLINE_API_TOKEN) {
|
|
||||||
console.error("Error: OUTLINE_URL and OUTLINE_API_TOKEN env vars are required.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function outlineApi(endpoint, body) {
|
|
||||||
const res = await fetch(`${OUTLINE_URL}/api/${endpoint}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`API ${endpoint} failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOrCreateCollection(name) {
|
|
||||||
// Search existing collections
|
|
||||||
const { data } = await outlineApi("collections.list", { limit: 100 });
|
|
||||||
const existing = data.find(
|
|
||||||
(c) => c.name.toLowerCase() === name.toLowerCase()
|
|
||||||
);
|
|
||||||
if (existing) return existing.id;
|
|
||||||
|
|
||||||
// Create new collection
|
|
||||||
await delay(RATE_LIMIT_MS);
|
|
||||||
const { data: created } = await outlineApi("collections.create", { name });
|
|
||||||
console.log(` Created collection: ${name}`);
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const files = (await fs.readdir(CONTENT_DIR)).filter((f) => f.endsWith(".md"));
|
|
||||||
console.log(`Found ${files.length} markdown files to migrate.\n`);
|
|
||||||
|
|
||||||
// Build a map of category → collection ID
|
|
||||||
const collectionIds = new Map();
|
|
||||||
|
|
||||||
let success = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(CONTENT_DIR, file);
|
|
||||||
const raw = await fs.readFile(filePath, "utf-8");
|
|
||||||
const { data: frontmatter, content } = matter(raw);
|
|
||||||
|
|
||||||
// Determine title: frontmatter title, or first H1, or filename
|
|
||||||
let title =
|
|
||||||
frontmatter.title ||
|
|
||||||
content.match(/^#\s+(.+)$/m)?.[1] ||
|
|
||||||
path.basename(file, ".md").replace(/-/g, " ");
|
|
||||||
|
|
||||||
// Determine collection from category
|
|
||||||
const category = frontmatter.category || "Uncategorized";
|
|
||||||
const categoryName = category
|
|
||||||
.replace(/-/g, " ")
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
if (!collectionIds.has(categoryName)) {
|
|
||||||
try {
|
|
||||||
const id = await getOrCreateCollection(categoryName);
|
|
||||||
collectionIds.set(categoryName, id);
|
|
||||||
await delay(RATE_LIMIT_MS);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(` Failed to get/create collection "${categoryName}": ${err.message}`);
|
|
||||||
failed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectionId = collectionIds.get(categoryName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: doc } = await outlineApi("documents.create", {
|
|
||||||
title,
|
|
||||||
text: content.trim(),
|
|
||||||
collectionId,
|
|
||||||
publish: true,
|
|
||||||
});
|
|
||||||
console.log(` ✓ ${title} → ${doc.url}`);
|
|
||||||
success++;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(` ✗ ${title}: ${err.message}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await delay(RATE_LIMIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nDone. ${success} migrated, ${failed} failed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Migration failed:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Outline Wiki Backup Script
|
|
||||||
# Backs up PostgreSQL database and file storage from Docker containers.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./outline-backup.sh [backup_dir]
|
|
||||||
#
|
|
||||||
# Crontab (daily at 3 AM):
|
|
||||||
# 0 3 * * * /path/to/outline-backup.sh /backups/outline >> /var/log/outline-backup.log 2>&1
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
BACKUP_DIR="${1:-/backups/outline}"
|
|
||||||
DATE=$(date +%Y-%m-%d_%H%M)
|
|
||||||
RETENTION_DAYS=14
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
echo "=== Outline Backup — $DATE ==="
|
|
||||||
|
|
||||||
# PostgreSQL dump
|
|
||||||
echo "Backing up PostgreSQL..."
|
|
||||||
docker exec outline-postgres pg_dump -U outline -Fc outline \
|
|
||||||
> "$BACKUP_DIR/outline-db-$DATE.dump"
|
|
||||||
echo " Database: outline-db-$DATE.dump"
|
|
||||||
|
|
||||||
# File storage backup
|
|
||||||
echo "Backing up file storage..."
|
|
||||||
docker cp outline:/var/lib/outline/data - \
|
|
||||||
| gzip > "$BACKUP_DIR/outline-files-$DATE.tar.gz"
|
|
||||||
echo " Files: outline-files-$DATE.tar.gz"
|
|
||||||
|
|
||||||
# Prune old backups
|
|
||||||
echo "Pruning backups older than $RETENTION_DAYS days..."
|
|
||||||
find "$BACKUP_DIR" -type f -mtime +$RETENTION_DAYS -delete
|
|
||||||
|
|
||||||
echo "=== Backup complete ==="
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"gray-matter": "^4.0.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
200
tailwind.config.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import defaultTheme from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./app/components/**/*.{js,vue,ts}",
|
||||||
|
"./app/layouts/**/*.vue",
|
||||||
|
"./app/pages/**/*.vue",
|
||||||
|
"./app/plugins/**/*.{js,ts}",
|
||||||
|
"./app/app.vue",
|
||||||
|
"./app/error.vue",
|
||||||
|
"./app/**/*.{js,vue,ts}",
|
||||||
|
"./content/**/*.{md,mdc,json,yml,yaml}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||||
|
serif: ["Crimson Text", "Georgia", ...defaultTheme.fontFamily.serif],
|
||||||
|
},
|
||||||
|
typography: ({ theme }) => ({
|
||||||
|
DEFAULT: {
|
||||||
|
css: {
|
||||||
|
fontFamily: "'Crimson Text', Georgia, serif",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
color: theme("colors.gray.900"),
|
||||||
|
maxWidth: "none",
|
||||||
|
h1: {
|
||||||
|
fontFamily: "'Crimson Text', Georgia, serif",
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: "2.5rem",
|
||||||
|
marginTop: "2rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
lineHeight: "1.2",
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontFamily: "'Crimson Text', Georgia, serif",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: "2rem",
|
||||||
|
marginTop: "1.75rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontFamily: "'Crimson Text', Georgia, serif",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
lineHeight: "1.4",
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
marginTop: "1.25rem",
|
||||||
|
marginBottom: "1.25rem",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
color: theme("colors.gray.700"),
|
||||||
|
},
|
||||||
|
li: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
},
|
||||||
|
"ul > li": {
|
||||||
|
paddingLeft: "1.75rem",
|
||||||
|
},
|
||||||
|
"ol > li": {
|
||||||
|
paddingLeft: "1.75rem",
|
||||||
|
},
|
||||||
|
"ol li::marker": {
|
||||||
|
color: theme("colors.gray.400"),
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
"ul li::marker": {
|
||||||
|
color: theme("colors.gray.400"),
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: theme("colors.blue.600"),
|
||||||
|
textDecoration: "underline",
|
||||||
|
fontWeight: "500",
|
||||||
|
"&:hover": {
|
||||||
|
color: theme("colors.blue.800"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily:
|
||||||
|
"'SFMono-Regular', ui-monospace, 'JetBrains Mono', monospace",
|
||||||
|
fontSize: "0.875em",
|
||||||
|
fontWeight: "500",
|
||||||
|
backgroundColor: theme("colors.gray.100"),
|
||||||
|
padding: "0.15em 0.35em",
|
||||||
|
borderRadius: theme("borderRadius.lg"),
|
||||||
|
},
|
||||||
|
"code::before": {
|
||||||
|
content: '""',
|
||||||
|
},
|
||||||
|
"code::after": {
|
||||||
|
content: '""',
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
fontStyle: "italic",
|
||||||
|
borderLeftColor: theme("colors.gray.300"),
|
||||||
|
borderLeftWidth: "0.25rem",
|
||||||
|
paddingLeft: "1rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
color: theme("colors.gray.600"),
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
fontFamily:
|
||||||
|
"'SFMono-Regular', ui-monospace, 'JetBrains Mono', monospace",
|
||||||
|
backgroundColor: theme("colors.gray.100"),
|
||||||
|
borderRadius: theme("borderRadius.xl"),
|
||||||
|
padding: "1.5rem",
|
||||||
|
color: theme("colors.gray.800"),
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: theme("colors.gray.200"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
css: {
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
p: {
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
},
|
||||||
|
li: {
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
fontSize: "3rem",
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: "2.25rem",
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: "1.75rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invert: {
|
||||||
|
css: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
h1: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: theme("colors.blue.400"),
|
||||||
|
"&:hover": {
|
||||||
|
color: theme("colors.blue.300"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeftColor: theme("colors.gray.700"),
|
||||||
|
color: theme("colors.gray.300"),
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
backgroundColor: theme("colors.gray.800"),
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
backgroundColor: theme("colors.gray.800"),
|
||||||
|
color: theme("colors.gray.100"),
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: theme("colors.gray.700"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
* Ghost Guild — Outline Wiki Custom Theme
|
|
||||||
*
|
|
||||||
* Injected via nginx sub_filter into every Outline page.
|
|
||||||
* Served from /custom/ghost-guild.css.
|
|
||||||
*
|
|
||||||
* NOTE: Outline's internal class names and DOM structure may change between
|
|
||||||
* versions. After upgrading Outline, verify these selectors still work.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Brand color overrides
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
:root {
|
|
||||||
--ghost-guild-primary: #2563eb;
|
|
||||||
--ghost-guild-bg: #fafafa;
|
|
||||||
--ghost-guild-text: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Hide Outline branding
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* "Built with Outline" footer link */
|
|
||||||
a[href="https://www.getoutline.com"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outline logo in sidebar */
|
|
||||||
[data-testid="sidebar-logo"],
|
|
||||||
a[href="/"] > svg {
|
|
||||||
/* Replace with Ghost Guild logo via background-image if desired:
|
|
||||||
background-image: url(/custom/logo.svg);
|
|
||||||
background-size: contain;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Typography
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* Placeholder: uncomment and update with Ghost Guild fonts
|
|
||||||
@font-face {
|
|
||||||
font-family: "GhostGuild";
|
|
||||||
src: url("/custom/fonts/ghost-guild.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Layout adjustments
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* Widen the document area slightly */
|
|
||||||
.document-editor,
|
|
||||||
[class*="DocumentEditor"] {
|
|
||||||
max-width: 48rem;
|
|
||||||
}
|
|
||||||
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||