Adding features

This commit is contained in:
Jennie Robinson Faber 2025-10-05 16:15:09 +01:00
parent 600fef2b7c
commit 2b55ca4104
75 changed files with 9796 additions and 2759 deletions

View file

@ -1,8 +1,13 @@
export default defineAppConfig({
ui: {
colors: {
primary: "pink",
neutral: "zinc",
primary: "emerald",
neutral: "stone",
},
formField: {
slots: {
label: "block font-medium text-stone-200",
},
},
},
});

View file

@ -2,13 +2,145 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
@theme static {
/* Font families */
--font-sans: "Inter", sans-serif;
--font-body: "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace;
--font-display: "NB Television Pro", monospace;
/* Ethereal color palette - grays, blacks, minimal color */
--color-ghost-50: #f0f0f0;
--color-ghost-100: #d0d0d0;
--color-ghost-200: #b0b0b0;
--color-ghost-300: #8a8a8a;
--color-ghost-400: #6a6a6a;
--color-ghost-500: #4a4a4a;
--color-ghost-600: #3a3a3a;
--color-ghost-700: #2a2a2a;
--color-ghost-800: #1a1a1a;
--color-ghost-900: #0a0a0a;
/* Subtle accent - barely visible blue-gray */
--color-whisper-50: #d4dae6;
--color-whisper-100: #a8b3c7;
--color-whisper-200: #8491a8;
--color-whisper-300: #687291;
--color-whisper-400: #4f5d7a;
--color-whisper-500: #3a4964;
--color-whisper-600: #2f3b52;
--color-whisper-700: #252d40;
--color-whisper-800: #1a1f2e;
--color-whisper-900: #0f1419;
/* Sparkle accent */
--color-sparkle-50: #fafafa;
--color-sparkle-100: #f0f0f0;
--color-sparkle-200: #e8e8e8;
--color-sparkle-300: #d0d0d0;
--color-sparkle-400: #c0c0c0;
--color-sparkle-500: #a0a0a0;
--color-sparkle-600: #808080;
--color-sparkle-700: #606060;
--color-sparkle-800: #404040;
--color-sparkle-900: #202020;
}
/* Global ethereal background */
:root {
--ethereal-bg: radial-gradient(circle at 20% 80%, rgba(232, 232, 232, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(232, 232, 232, 0.02) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(232, 232, 232, 0.01) 0%, transparent 50%);
--halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
--halftone-size: 8px 8px;
}
html {
background: var(--color-ghost-900);
color: var(--color-ghost-200);
}
body {
background: var(--ethereal-bg), var(--color-ghost-900);
background-attachment: fixed;
}
/* Halftone texture overlay */
.halftone-texture {
position: relative;
}
.halftone-texture::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--halftone-pattern);
background-size: var(--halftone-size);
opacity: 0.1;
pointer-events: none;
}
/* Sparkle effects */
@keyframes sparkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.2; }
25% { opacity: 0.8; }
75% { opacity: 0.4; }
}
.sparkle-field {
position: relative;
overflow: hidden;
}
.sparkle-field::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 10% 20%, var(--color-sparkle-200) 1px, transparent 1px),
radial-gradient(circle at 90% 80%, var(--color-sparkle-400) 1px, transparent 1px),
radial-gradient(circle at 30% 70%, var(--color-sparkle-200) 0.5px, transparent 0.5px),
radial-gradient(circle at 70% 30%, var(--color-sparkle-400) 0.5px, transparent 0.5px),
radial-gradient(circle at 50% 10%, var(--color-sparkle-200) 1px, transparent 1px),
radial-gradient(circle at 20% 90%, var(--color-sparkle-400) 0.5px, transparent 0.5px);
background-size: 200px 200px, 300px 300px, 150px 150px, 250px 250px, 180px 180px, 220px 220px;
animation: twinkle 4s infinite ease-in-out;
pointer-events: none;
opacity: 0.6;
}
/* Ethereal glow effects */
.ethereal-glow {
box-shadow:
0 0 20px rgba(232, 232, 232, 0.1),
0 0 40px rgba(232, 232, 232, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.ethereal-text {
text-shadow: 0 0 10px rgba(232, 232, 232, 0.3);
}
/* Dithered gradients */
.dithered-bg {
background:
linear-gradient(45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-ghost-700) 75%);
background-size: 4px 4px;
background-position: 0 0, 0 2px, 2px -2px, -2px 0px;
}

View file

@ -1,288 +1,31 @@
<template>
<footer
class="py-16 border-t"
:class="[
backgroundClass,
borderClass
]"
>
<UContainer>
<!-- Main Footer Content -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
<!-- Brand Section -->
<div class="lg:col-span-1">
<div class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :class="logoBackgroundClass">
<div class="w-4 h-4 bg-white rounded-sm" />
</div>
<div class="w-6 h-6" :class="logoBackgroundClass" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
<span class="text-2xl font-bold ml-2" :class="brandTextClass">{{ brandName }}</span>
</div>
<p :class="textColorClass" class="text-sm leading-relaxed">
{{ description }}
<footer class="mt-32 pb-16 px-8 md:px-12 lg:px-16">
<!-- Minimal footer content -->
<div class="max-w-4xl">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-end gap-8"
>
<!-- Left: Copyright and minimal info -->
<div>
<p class="text-stone-500 text-xs mb-2">
© {{ currentYear }} Ghost Guild
</p>
</div>
<!-- Navigation Links -->
<div class="lg:col-span-1">
<h3 class="font-semibold mb-4" :class="headingColorClass">Navigation</h3>
<ul class="space-y-2">
<li v-for="link in navigationLinks" :key="link.path">
<NuxtLink
:to="link.path"
:class="linkColorClass"
class="text-sm hover:underline transition-colors"
>
{{ link.label }}
</NuxtLink>
</li>
</ul>
</div>
<!-- Community Links -->
<div class="lg:col-span-1">
<h3 class="font-semibold mb-4" :class="headingColorClass">Community</h3>
<ul class="space-y-2">
<li v-for="link in communityLinks" :key="link.path">
<NuxtLink
:to="link.path"
:class="linkColorClass"
class="text-sm hover:underline transition-colors"
>
{{ link.label }}
</NuxtLink>
</li>
</ul>
</div>
<!-- Contact/Social -->
<div class="lg:col-span-1">
<h3 class="font-semibold mb-4" :class="headingColorClass">Connect</h3>
<ul class="space-y-2">
<li v-for="link in socialLinks" :key="link.href">
<a
:href="link.href"
:class="linkColorClass"
class="text-sm hover:underline transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{{ link.label }}
</a>
</li>
</ul>
<!-- Right: Contact links -->
<div class="flex flex-wrap gap-6 text-xs">
<a
href="mailto:hello@ghostguild.org"
class="text-stone-500 hover:text-stone-300 transition-colors"
>
Contact
</a>
</div>
</div>
<!-- Decorative Elements (matching wireframe) -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :class="logoBackgroundClass">
<div class="w-4 h-4 bg-white rounded-sm" />
</div>
<div class="w-6 h-6" :class="logoBackgroundClass" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<div class="hidden md:flex items-center gap-8">
<div class="space-y-2">
<div class="h-1 w-16 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-12 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-14 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
<div class="space-y-2">
<div class="h-1 w-12 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-16 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-10 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
<div class="space-y-2">
<div class="h-1 w-14 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-10 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-16 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
</div>
</div>
<!-- Copyright -->
<div class="pt-8 text-center" :class="borderClass">
<p :class="textColorClass" class="text-sm">
© {{ currentYear }} {{ brandName }}. {{ copyrightText }}
</p>
</div>
</UContainer>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
theme: {
type: String,
default: 'purple',
validator: (value) => ['purple', 'blue', 'emerald', 'gray'].includes(value)
},
brandName: {
type: String,
default: 'Ghost Guild'
},
description: {
type: String,
default: 'A community for game developers exploring cooperative models and building sustainable studios together.'
},
copyrightText: {
type: String,
default: 'All rights reserved.'
},
customNavigationLinks: {
type: Array,
default: () => []
},
customCommunityLinks: {
type: Array,
default: () => []
},
customSocialLinks: {
type: Array,
default: () => []
}
})
const currentYear = new Date().getFullYear()
const navigationLinks = computed(() => {
if (props.customNavigationLinks.length > 0) {
return props.customNavigationLinks
}
return [
{ label: 'Home', path: '/' },
{ label: 'About', path: '/about' },
{ label: 'Events', path: '/events' },
{ label: 'Join', path: '/join' },
{ label: 'Contact', path: '/contact' }
]
})
const communityLinks = computed(() => {
if (props.customCommunityLinks.length > 0) {
return props.customCommunityLinks
}
return [
{ label: 'Upcoming Events', path: '/events' },
{ label: 'Past Events', path: '/events/past' },
{ label: 'Event Calendar', path: '/events/calendar' },
{ label: 'Members Directory', path: '/members' }
]
})
const socialLinks = computed(() => {
if (props.customSocialLinks.length > 0) {
return props.customSocialLinks
}
return [
{ label: 'Discord Community', href: 'https://discord.gg/ghostguild' },
{ label: 'Twitter', href: 'https://twitter.com/ghostguild' },
{ label: 'GitHub', href: 'https://github.com/ghostguild' },
{ label: 'Contact Us', href: 'mailto:hello@ghostguild.org' }
]
})
const backgroundClass = computed(() => {
const themes = {
purple: 'bg-purple-50 dark:bg-purple-900/20',
blue: 'bg-blue-50 dark:bg-blue-900/20',
emerald: 'bg-emerald-50 dark:bg-emerald-900/20',
gray: 'bg-gray-50 dark:bg-gray-900'
}
return themes[props.theme] || themes.purple
})
const borderClass = computed(() => {
const themes = {
purple: 'border-purple-200 dark:border-purple-800',
blue: 'border-blue-200 dark:border-blue-800',
emerald: 'border-emerald-200 dark:border-emerald-800',
gray: 'border-gray-200 dark:border-gray-700'
}
return themes[props.theme] || themes.purple
})
const logoBackgroundClass = computed(() => {
const themes = {
purple: 'bg-purple-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
gray: 'bg-gray-500'
}
return themes[props.theme] || themes.purple
})
const brandTextClass = computed(() => {
const themes = {
purple: 'text-purple-600 dark:text-purple-400',
blue: 'text-blue-600 dark:text-blue-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.purple
})
const headingColorClass = computed(() => {
const themes = {
purple: 'text-purple-900 dark:text-purple-100',
blue: 'text-blue-900 dark:text-blue-100',
emerald: 'text-emerald-900 dark:text-emerald-100',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.purple
})
const textColorClass = computed(() => {
const themes = {
purple: 'text-purple-600 dark:text-purple-400',
blue: 'text-blue-600 dark:text-blue-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-600 dark:text-gray-400'
}
return themes[props.theme] || themes.purple
})
const linkColorClass = computed(() => {
const themes = {
purple: 'text-purple-700 dark:text-purple-300 hover:text-purple-900 dark:hover:text-purple-100',
blue: 'text-blue-700 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-100',
emerald: 'text-emerald-700 dark:text-emerald-300 hover:text-emerald-900 dark:hover:text-emerald-100',
gray: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100'
}
return themes[props.theme] || themes.purple
})
const decorativeBarClass = computed(() => {
const themes = {
purple: 'bg-purple-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
gray: 'bg-gray-500'
}
return themes[props.theme] || themes.purple
})
const decorativeBarSecondaryClass = computed(() => {
const themes = {
purple: 'bg-purple-400',
blue: 'bg-blue-400',
emerald: 'bg-emerald-400',
gray: 'bg-gray-400'
}
return themes[props.theme] || themes.purple
})
const decorativeBarTertiaryClass = computed(() => {
const themes = {
purple: 'bg-purple-300',
blue: 'bg-blue-300',
emerald: 'bg-emerald-300',
gray: 'bg-gray-300'
}
return themes[props.theme] || themes.purple
})
</script>
const currentYear = new Date().getFullYear();
</script>

View file

@ -1,142 +1,191 @@
<template>
<nav class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<UContainer>
<div class="flex items-center justify-between py-4">
<!-- Logo/Brand -->
<NuxtLink to="/" class="flex items-center gap-2">
<div class="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center">
<div class="w-4 h-4 bg-white rounded-sm" />
</div>
<div class="w-6 h-6 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
<span class="text-xl font-bold text-gray-900 dark:text-white ml-2">Ghost Guild</span>
</NuxtLink>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
<NuxtLink
v-for="item in navigationItems"
:key="item.path"
:to="item.path"
class="text-gray-600 dark:text-gray-300 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors font-medium"
active-class="text-emerald-600 dark:text-emerald-400"
>
{{ item.label }}
</NuxtLink>
<!-- Auth-based buttons -->
<div v-if="isAuthenticated" class="flex items-center gap-3 ml-4">
<UButton
to="/member/dashboard"
variant="solid"
size="sm"
>
Dashboard
</UButton>
<UButton
@click="logout"
variant="outline"
size="sm"
>
Logout
</UButton>
</div>
<UButton
v-else
to="/login"
variant="outline"
size="sm"
class="ml-4"
>
Login
</UButton>
</div>
<!-- Mobile Menu Button -->
<button
class="md:hidden p-2"
@click="toggleMobileMenu"
aria-label="Toggle menu"
<nav
class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
>
<!-- Logo/Brand at top -->
<div class="p-8 border-b border-ghost-800 bg-blue-400">
<NuxtLink to="/" class="flex flex-col items-center gap-3 group">
<span
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
>Ghost Guild Logo</span
>
<div class="space-y-1">
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ 'rotate-45 translate-y-1.5': mobileMenuOpen }" />
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ 'opacity-0': mobileMenuOpen }" />
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ '-rotate-45 -translate-y-1.5': mobileMenuOpen }" />
</div>
</button>
</div>
<!-- Mobile Navigation -->
<div
v-if="mobileMenuOpen"
class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex flex-col space-y-3">
<NuxtLink
v-for="item in navigationItems"
:key="item.path"
:to="item.path"
class="text-gray-600 dark:text-gray-300 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors font-medium py-2"
active-class="text-emerald-600 dark:text-emerald-400"
@click="mobileMenuOpen = false"
>
{{ item.label }}
</NuxtLink>
</div>
<!-- Vertical Navigation -->
<div class="flex-1 p-8 overflow-y-auto">
<ul class="space-y-6">
<li v-for="item in navigationItems" :key="item.path">
<NuxtLink :to="item.path" class="block group relative">
<!-- Hover indicator -->
<span
class="text-stone-200 hover:text-stone-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
active-class="text-stone-100 ethereal-text translate-x-2"
>
{{ item.label }}
</span>
</NuxtLink>
<!-- Mobile auth buttons -->
<div v-if="isAuthenticated" class="flex flex-col gap-3 mt-4">
<UButton
to="/member/dashboard"
variant="solid"
size="sm"
class="w-fit"
@click="mobileMenuOpen = false"
>
Dashboard
</UButton>
<UButton
@click="logout; mobileMenuOpen = false"
variant="outline"
size="sm"
class="w-fit"
>
Logout
</UButton>
</div>
<UButton
v-else
to="/login"
variant="outline"
size="sm"
class="mt-4 w-fit"
@click="mobileMenuOpen = false"
</li>
</ul>
<!-- Auth section -->
<div class="mt-12 pt-8 border-t border-ghost-800/50">
<div v-if="isAuthenticated" class="space-y-4">
<NuxtLink
to="/member/dashboard"
class="block text-stone-300 hover:text-stone-100 hover:ethereal-text transition-all duration-300 py-2"
>
Login
</UButton>
<span class="block text-sm text-whisper-400 mb-1">{{
memberData?.name || "Member"
}}</span>
Dashboard
</NuxtLink>
<button
@click="logout"
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm"
>
Logout
</button>
</div>
<div v-else class="space-y-4">
<p class="text-stone-400 text-sm mb-4">
Enter your email to receive a login link
</p>
<UForm :state="loginForm" @submit="handleLogin">
<UFormField name="email">
<UInput
v-model="loginForm.email"
type="email"
size="md"
placeholder="your.email@example.com"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isLoginFormValid"
size="md"
class="w-full mt-3"
>
Send Magic Link
</UButton>
</UForm>
<div
v-if="loginSuccess"
class="p-3 bg-green-900/20 rounded border border-green-800"
>
<p class="text-green-300 text-sm"> Check your email!</p>
</div>
<div
v-if="loginError"
class="p-3 bg-red-900/20 rounded border border-red-800"
>
<p class="text-red-300 text-sm">{{ loginError }}</p>
</div>
</div>
</div>
</UContainer>
</div>
</nav>
</template>
<script setup>
import { ref } from 'vue'
import { reactive, ref, computed } from "vue";
const mobileMenuOpen = ref(false)
const { isAuthenticated, logout } = useAuth()
const { isAuthenticated, logout, memberData } = useAuth();
const navigationItems = [
{ label: 'Home', path: '/' },
{ label: 'About', path: '/about' },
{ label: 'Events', path: '/events' },
{ label: 'Members', path: '/members' },
{ label: 'Join', path: '/join' },
{ label: 'Contact', path: '/contact' },
]
const publicNavigationItems = [
{ label: "Home", path: "/", accent: "entry point" },
{ label: "About", path: "/about", accent: "who we are" },
{ label: "Events", path: "/events", accent: "gatherings" },
{ label: "Join", path: "/join", accent: "become one" },
{ label: "Contact", path: "/contact", accent: "reach out" },
];
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
const memberNavigationItems = [
{ label: "Dashboard", path: "/member/dashboard" },
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Resources", path: "/resources" },
{ label: "Updates", path: "/updates" },
{ label: "Peer Support", path: "/peer-support" },
{ label: "Profile", path: "/member/profile" },
];
const navigationItems = computed(() =>
isAuthenticated.value ? memberNavigationItems : publicNavigationItems,
);
// Login form state
const loginForm = reactive({
email: "",
});
const isLoggingIn = ref(false);
const loginSuccess = ref(false);
const loginError = ref("");
const isLoginFormValid = computed(() => {
return loginForm.email && loginForm.email.includes("@");
});
const handleLogin = async () => {
if (isLoggingIn.value) return;
isLoggingIn.value = true;
loginError.value = "";
loginSuccess.value = false;
try {
const response = await $fetch("/api/auth/login", {
method: "POST",
body: {
email: loginForm.email,
},
});
if (response.success) {
loginSuccess.value = true;
loginError.value = "";
loginForm.email = "";
}
} catch (err) {
console.error("Login error:", err);
if (err.statusCode === 404) {
loginError.value = "No account found";
} else if (err.statusCode === 500) {
loginError.value = "Failed to send email";
} else {
loginError.value = "Something went wrong";
}
} finally {
isLoggingIn.value = false;
}
};
</script>
<style scoped>
@keyframes float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-5px) rotate(10deg);
}
}
// Close mobile menu when clicking outside
const closeMobileMenu = () => {
mobileMenuOpen.value = false
.delay-75 {
animation-delay: 75ms;
}
</script>
.delay-150 {
animation-delay: 150ms;
}
</style>

View file

@ -1,65 +1,108 @@
<template>
<header
class="py-16 md:py-24"
:class="[
backgroundClass,
textColorClass
]"
<header
class="relative py-16 md:py-24 bg-cover bg-center"
style="background-image: url(&quot;/background-dither.png&quot;)"
>
<UContainer>
<div class="absolute inset-0 bg-black/40"></div>
<UContainer class="relative z-10">
<div class="text-center max-w-4xl mx-auto">
<h1
<h1
class="font-bold mb-6 md:mb-8"
:class="[
titleSizeClass,
titleColorClass
]"
:class="[titleSizeClass, titleColorClass]"
>
{{ title }}
</h1>
<p
<p
v-if="subtitle"
class="text-lg md:text-xl leading-relaxed mb-8"
:class="subtitleColorClass"
>
{{ subtitle }}
</p>
<!-- Interactive Content Area (for hero sections with carousels, etc.) -->
<div v-if="showInteractiveArea" class="bg-white dark:bg-gray-800 rounded-2xl p-6 md:p-8 shadow-xl border border-blue-200 dark:border-blue-800 mb-12">
<div
v-if="showInteractiveArea"
:class="[
'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm',
props.theme === 'ethereal'
? 'bg-ghost-800/60 border border-ghost-700 ethereal-glow halftone-texture'
: 'bg-white dark:bg-gray-800 shadow-xl border border-blue-200 dark:border-blue-800',
]"
>
<div class="flex items-center justify-between">
<button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
<button
:class="[
'p-3 rounded-full transition-all duration-300',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('prev')"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div class="text-center flex-1">
<slot name="interactive-content">
<p class="text-lg text-gray-600 dark:text-gray-300">
{{ interactiveContent || 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }}
<p
:class="[
'text-lg',
props.theme === 'ethereal'
? 'text-stone-200'
: 'text-gray-600 dark:text-gray-300',
]"
>
{{
interactiveContent ||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
}}
</p>
</slot>
</div>
<button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
<button
:class="[
'p-3 rounded-full transition-all duration-300',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('next')"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
<!-- Call to Action Button -->
<div v-if="showCta" class="flex justify-center">
<UButton
<UButton
:to="ctaLink"
:size="ctaSize"
:color="ctaColor"
@ -68,7 +111,7 @@
{{ ctaText }}
</UButton>
</div>
<!-- Custom Content Slot -->
<div v-if="$slots.default">
<slot />
@ -79,94 +122,93 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed } from "vue";
const props = defineProps({
title: {
type: String,
required: true
required: true,
},
subtitle: {
type: String,
default: ''
default: "",
},
theme: {
type: String,
default: 'blue',
validator: (value) => ['blue', 'purple', 'emerald', 'gray'].includes(value)
default: "blue",
validator: (value) =>
["blue", "purple", "emerald", "gray", "ethereal"].includes(value),
},
size: {
type: String,
default: 'large',
validator: (value) => ['small', 'medium', 'large', 'hero'].includes(value)
default: "large",
validator: (value) => ["small", "medium", "large", "hero"].includes(value),
},
showInteractiveArea: {
type: Boolean,
default: false
default: false,
},
interactiveContent: {
type: String,
default: ''
default: "",
},
showCta: {
type: Boolean,
default: false
default: false,
},
ctaText: {
type: String,
default: 'Get Started'
default: "Get Started",
},
ctaLink: {
type: String,
default: '/join'
default: "/join",
},
ctaSize: {
type: String,
default: 'lg'
default: "lg",
},
ctaColor: {
type: String,
default: 'primary'
}
})
default: "primary",
},
});
defineEmits(['prev', 'next'])
defineEmits(["prev", "next"]);
const backgroundClass = computed(() => {
const themes = {
blue: 'bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800',
purple: 'bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20',
emerald: 'bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20',
gray: 'bg-gray-50 dark:bg-gray-900'
}
return themes[props.theme] || themes.blue
})
blue: "bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800",
purple:
"bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20",
emerald:
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20",
gray: "bg-gray-50 dark:bg-gray-900",
ethereal:
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
};
return themes[props.theme] || themes.blue;
});
const titleSizeClass = computed(() => {
const sizes = {
small: 'text-2xl md:text-3xl',
medium: 'text-3xl md:text-4xl',
large: 'text-4xl md:text-5xl',
hero: 'text-5xl md:text-6xl'
}
return sizes[props.size] || sizes.large
})
small: "text-2xl md:text-3xl",
medium: "text-3xl md:text-4xl",
large: "text-4xl md:text-5xl",
hero: "text-5xl md:text-6xl",
};
return sizes[props.size] || sizes.large;
});
const titleColorClass = computed(() => {
const themes = {
blue: 'text-blue-600 dark:text-blue-400',
purple: 'text-purple-600 dark:text-purple-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.blue
})
return "text-white";
});
const subtitleColorClass = computed(() => {
return 'text-gray-600 dark:text-gray-300'
})
return "text-white";
});
const textColorClass = computed(() => {
return 'text-gray-900 dark:text-white'
})
</script>
return "text-white";
});
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400">{{ label }}:</span>
<UButtonGroup size="xs">
<UButton
:variant="modelValue === 'public' ? 'solid' : 'ghost'"
:color="modelValue === 'public' ? 'blue' : 'gray'"
@click="updateValue('public')"
>
Public
</UButton>
<UButton
:variant="modelValue === 'members' ? 'solid' : 'ghost'"
:color="modelValue === 'members' ? 'blue' : 'gray'"
@click="updateValue('members')"
>
Members
</UButton>
<UButton
:variant="modelValue === 'private' ? 'solid' : 'ghost'"
:color="modelValue === 'private' ? 'blue' : 'gray'"
@click="updateValue('private')"
>
Private
</UButton>
</UButtonGroup>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: 'members'
},
label: {
type: String,
default: 'Privacy'
}
})
const emit = defineEmits(['update:modelValue'])
const updateValue = (value) => {
emit('update:modelValue', value)
}
</script>

View file

@ -0,0 +1,189 @@
<template>
<UCard variant="outline" class="update-card">
<div class="flex gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
v-if="update.author?.avatar"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-12 h-12 rounded-full"
@error="handleImageError"
/>
<div
v-else
class="w-12 h-12 rounded-full bg-stone-700 flex items-center justify-center text-stone-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-stone-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-stone-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-stone-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-stone-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
>
Public
</span>
</div>
</div>
<!-- Actions (for author only) -->
<div v-if="isAuthor" class="flex gap-2">
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-edit"
@click="$emit('edit', update)"
/>
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-trash-2"
@click="$emit('delete', update)"
/>
</div>
</div>
<!-- Content -->
<div class="text-stone-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-stone-400 hover:text-stone-300 ml-1"
>
Read more
</NuxtLink>
</template>
<template v-else>
{{ update.content }}
</template>
</div>
<!-- Images (if any) -->
<div v-if="update.images?.length" class="mb-3 space-y-2">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="rounded-lg max-w-full h-auto"
/>
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-stone-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-stone-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-stone-500">
Comments (coming soon)
</span>
</div>
</div>
</div>
</UCard>
</template>
<script setup>
const props = defineProps({
update: {
type: Object,
required: true,
},
showPreview: {
type: Boolean,
default: true,
},
});
defineEmits(["edit", "delete"]);
const { memberData } = useAuth();
const isAuthor = computed(() => {
return memberData.value && props.update.author?._id === memberData.value.id;
});
const isEdited = computed(() => {
const created = new Date(props.update.createdAt).getTime();
const updated = new Date(props.update.updatedAt).getTime();
return updated - created > 1000; // More than 1 second difference
});
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const handleImageError = (e) => {
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
};
const formatDate = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600)
return `${Math.floor(diffInSeconds / 60)} minutes ago`;
if (diffInSeconds < 86400)
return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 604800)
return `${Math.floor(diffInSeconds / 86400)} days ago`;
return updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
</script>
<style scoped>
.update-card {
background-color: rgb(41 37 36);
border-color: rgb(87 83 78);
}
.update-card:hover {
border-color: rgb(120 113 108);
}
:deep(.card) {
background-color: rgb(41 37 36);
}
</style>

View file

@ -0,0 +1,182 @@
<template>
<div class="space-y-6">
<UFormField label="What's on your mind?" name="content" required>
<UTextarea
v-model="formData.content"
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
:rows="8"
autoresize
:maxrows="20"
/>
</UFormField>
<!-- Privacy Settings -->
<div class="border border-stone-700 rounded-lg p-4 bg-stone-800/30">
<h3 class="text-sm font-medium text-stone-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Public</div>
<div class="text-sm text-stone-400">
Visible to everyone, including non-members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Members Only</div>
<div class="text-sm text-stone-400">
Only visible to Ghost Guild members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Private</div>
<div class="text-sm text-stone-400">Only visible to you</div>
</div>
</label>
</div>
</div>
<!-- Image Upload (Future) -->
<!-- TODO: Add image upload integration with Cloudinary -->
<!-- Comments Toggle -->
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-stone-200 font-medium">Enable Comments</div>
<div class="text-sm text-stone-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-4 border-t border-stone-700">
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>
<UButton
:loading="submitting"
:disabled="!formData.content.trim()"
@click="handleSubmit"
>
{{ submitLabel }}
</UButton>
</div>
<!-- Error Message -->
<div
v-if="error"
class="bg-red-500/10 border border-red-500/30 rounded-lg p-4"
>
<p class="text-red-300">{{ error }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
initialData: {
type: Object,
default: () => ({
content: "",
privacy: "members",
commentsEnabled: true,
images: [],
}),
},
submitLabel: {
type: String,
default: "Post Update",
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["submit", "cancel"]);
const formData = reactive({
content: props.initialData.content || "",
privacy: props.initialData.privacy || "members",
commentsEnabled: props.initialData.commentsEnabled ?? true,
images: props.initialData.images || [],
});
const handleSubmit = () => {
if (!formData.content.trim()) return;
emit("submit", { ...formData });
};
// Watch for initialData changes (for edit mode)
watch(
() => props.initialData,
(newData) => {
if (newData) {
formData.content = newData.content || "";
formData.privacy = newData.privacy || "members";
formData.commentsEnabled = newData.commentsEnabled ?? true;
formData.images = newData.images || [];
}
},
{ immediate: true },
);
</script>
<style scoped>
/* Field labels */
:deep(label) {
color: rgb(231 229 228) !important;
font-weight: 500;
}
/* Textarea styling */
:deep(textarea) {
background-color: rgb(41 37 36) !important;
color: rgb(231 229 228) !important;
border-color: rgb(87 83 78) !important;
}
:deep(textarea::placeholder) {
color: rgb(120 113 108) !important;
}
:deep(textarea:focus) {
border-color: rgb(168 162 158) !important;
background-color: rgb(44 40 39) !important;
}
/* Radio buttons */
input[type="radio"] {
accent-color: rgb(168 162 158);
}
</style>

View file

@ -1,7 +1,35 @@
<template>
<div>
<AppNavigation />
<slot />
<AppFooter />
<div class="min-h-screen bg-stone-800 flex relative">
<!-- Background image at top - full page width -->
<div
class="absolute inset-x-0 pointer-events-none z-0"
style="
background-image: url(&quot;/background-dither.png&quot;);
background-size: 100% auto;
background-position: top center;
background-repeat: no-repeat;
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0) 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0) 100%
);
"
/>
<!-- Main Content Column - Left -->
<div class="flex-1 overflow-y-auto relative z-[5]">
<div class="p-8 md:p-12 lg:p-16 max-w-4xl relative">
<slot />
</div>
<AppFooter />
</div>
<!-- Navigation Column - Right -->
<AppNavigation class="relative z-20" />
</div>
</template>
</template>

View file

@ -1,306 +1,278 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="About"
subtitle="Learn about Ghost Guild, our mission to support cooperative game development, and the community we're building together."
<PageHeader
title="About Our Membership Circles"
subtitle="All members of Ghost Guild share the Baby Ghosts mission: Advancing cooperative and worker-centric labour models in the Canadian interactive digital arts sector."
theme="blue"
size="large"
/>
<!-- About Ghost Guild -->
<!-- How Ghost Guild Works -->
<section class="py-20 bg-white dark:bg-gray-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
About Ghost Guild
</h2>
</div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
How Ghost Guild Works
</h2>
<div class="space-y-8">
<!-- Main Description with Progress Bars -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800">
<div class="space-y-6 mb-8">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-2/3" />
</div>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild is a cooperative community dedicated to supporting game developers who want to build sustainable, worker-owned studios. We believe in the power of collaboration and shared ownership to create better working conditions and more innovative games.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Our community provides resources, mentorship, and financial support to help developers transition from traditional employment to cooperative ownership models. We're building a network of studios that prioritize worker wellbeing and creative freedom.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Through our various circles and contribution-based membership model, we create an inclusive space where developers at all stages of their cooperative journey can find support and guidance.
</p>
</div>
</div>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
Everyone gets everything. Your circle reflects where you are in
your cooperative journey. Your financial contribution reflects
what you can afford. These are completely separate choices.
</p>
<!-- Mission Statement -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">Our Mission</h3>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. To democratize game development by empowering developers to create worker-owned studios that prioritize sustainability, creativity, and fair compensation.
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">Our Vision</h3>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. A thriving ecosystem of cooperative game studios that create innovative experiences while providing their worker-owners with meaningful, sustainable careers.
</p>
</div>
</div>
<ul
class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-3 mb-12"
>
<li>
<strong>Equal access:</strong> The entire knowledge commons, all
events, and full community participation
</li>
<li>
<strong>Equal voice:</strong> One member, one vote in all
decisions
</li>
<li>
<strong>Solidarity economics:</strong> Pay what you can
($0-50+/month), take what you need
</li>
<li>
<strong>Value Flow integration:</strong> Contribute your skills,
time, and knowledge - not just money
</li>
</ul>
</div>
</div>
</UContainer>
</section>
<!-- Who It's For -->
<!-- Find Your Circle -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Who It's For
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Find Your Circle
</h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild welcomes developers from all backgrounds and experience levels.
<p class="text-lg text-gray-700 dark:text-gray-300 mb-12">
Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are, not what
you want to access.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 max-w-5xl mx-auto">
<!-- Game Developers -->
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center">
<div class="w-8 h-8 bg-blue-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Game Developers
<div class="space-y-12">
<!-- Community Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Community Circle
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-300 rounded-full w-3/4" />
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Whether you're a solo indie developer, part of a small team, or working at a larger studio, our community provides resources for exploring cooperative models and building sustainable careers in game development.
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
You're exploring what cooperatives could mean for your work
</p>
</div>
</div>
<!-- Studio Founders -->
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center">
<div class="w-8 h-8 bg-emerald-500 rounded" />
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>
Curious about alternatives to traditional studio structures
</li>
<li>Researching cooperative principles</li>
<li>Considering if a co-op is right for you</li>
<li>Supporting the movement as an ally</li>
</ul>
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Studio Founders
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6" />
<div class="h-1 bg-emerald-200 rounded-full w-2/3" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Entrepreneurs and leaders who want to build studios that prioritize worker ownership, democratic decision-making, and sustainable business practices will find mentorship and practical guidance in our community.
</p>
</div>
</div>
<!-- Industry Allies -->
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center">
<div class="w-8 h-8 bg-purple-500 rounded-full" />
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Understanding cooperative basics</li>
<li>Connecting with others asking similar questions</li>
<li>Exploring real examples from game studios</li>
<li>Deciding your next steps</li>
</ul>
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Industry Allies
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-purple-500 rounded-full w-4/5" />
<div class="h-1 bg-purple-300 rounded-full w-full" />
<div class="h-1 bg-purple-200 rounded-full w-3/5" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Investors, publishers, service providers, and other industry professionals who want to support cooperative game development and learn about alternative business models.
</p>
</div>
</div>
<!-- Researchers & Advocates -->
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-yellow-100 dark:bg-yellow-900/30 rounded-2xl flex items-center justify-center">
<div class="w-8 h-8 bg-yellow-500 rounded-sm" />
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Individual game workers</li>
<li>Researchers and students</li>
<li>Industry allies and supporters</li>
<li>Anyone co-op-curious</li>
</ul>
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Researchers & Advocates
<!-- Founder Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Founder Circle
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-yellow-500 rounded-full w-3/4" />
<div class="h-1 bg-yellow-300 rounded-full w-full" />
<div class="h-1 bg-yellow-200 rounded-full w-1/2" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Academics, journalists, and advocates studying cooperative economics, worker ownership, and alternative organizational structures in the creative industries.
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
You're actively building or transitioning to a cooperative model
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Forming a new cooperative studio</li>
<li>Converting an existing studio to a co-op</li>
<li>Preparing to apply for the Peer Accelerator</li>
<li>Working through governance and structure decisions</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Practical implementation challenges</li>
<li>Governance document creation</li>
<li>Financial planning for co-ops</li>
<li>Peer connections with other founders</li>
<li>Balancing ideals with sustainability</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Two paths available:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>
<strong>Peer Accelerator Prep Track:</strong> Structured
preparation for the PA program
</li>
<li>
<strong>Indie Track:</strong> Self-paced development for
alternative pathways
</li>
</ul>
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Founding teams</li>
<li>Studios in transition</li>
<li>PA program applicants</li>
<li>Solo founders exploring structures</li>
</ul>
</div>
</div>
<!-- Practitioner Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Practitioner Circle
</h3>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
You're operating a cooperative and contributing to the field
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Running an established cooperative studio</li>
<li>Graduated from the Peer Accelerator</li>
<li>Mentoring other cooperatives</li>
<li>Advancing cooperative practices in games</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Advanced operational challenges</li>
<li>Opportunities to mentor and teach</li>
<li>Contributing to best practices</li>
<li>Cross-pollination with other co-ops</li>
<li>Research and publication opportunities</li>
<li>Co-op to co-op collaboration</li>
</ul>
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Peer Accelerator alumni</li>
<li>Established co-op members</li>
<li>Industry mentors</li>
<li>Cooperative researchers</li>
</ul>
</div>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Our Values -->
<!-- Important Notes -->
<section class="py-20 bg-white dark:bg-gray-900">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Our Values
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
Important Notes
</h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. These core principles guide everything we do at Ghost Guild.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Cooperation -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Cooperation
</h3>
<div class="space-y-2">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-400 rounded-full w-3/4 mx-auto" />
<div class="h-1 bg-blue-300 rounded-full w-1/2 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We believe in the power of working together, sharing knowledge, and supporting each other's success rather than competing for scarce resources.
<div class="space-y-6 text-lg text-gray-700 dark:text-gray-300">
<p>
<strong>Movement between circles is fluid.</strong> As you move
along in your journey, you can shift circles anytime. Just let us
know.
</p>
</div>
<!-- Sustainability -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Sustainability
</h3>
<div class="space-y-2">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-400 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-emerald-300 rounded-full w-2/3 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We prioritize long-term thinking, environmental responsibility, and creating work cultures that support developer wellbeing and work-life balance.
<p>
<strong>Your contribution is separate from your circle.</strong>
Whether you contribute $0 or $50+/month, you get full access to
everything. Choose based on your financial capacity, not your
circle.
</p>
</div>
<!-- Democracy -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Democracy
</h3>
<div class="space-y-2">
<div class="h-1 bg-purple-500 rounded-full w-full" />
<div class="h-1 bg-purple-400 rounded-full w-4/5 mx-auto" />
<div class="h-1 bg-purple-300 rounded-full w-3/5 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We advocate for democratic decision-making processes where all workers have a voice in shaping their workplace and the direction of their studio.
<p>
<strong>Not sure which circle?</strong> Start with Community - you
can always move. Or email us and we'll chat about what makes sense
for you.
</p>
</div>
<!-- Transparency -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Transparency
</h3>
<div class="space-y-2">
<div class="h-1 bg-cyan-500 rounded-full w-full" />
<div class="h-1 bg-cyan-400 rounded-full w-3/5 mx-auto" />
<div class="h-1 bg-cyan-300 rounded-full w-4/5 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We promote open communication, shared financial information, and clear decision-making processes that build trust and accountability.
</p>
</div>
<!-- Innovation -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Innovation
</h3>
<div class="space-y-2">
<div class="h-1 bg-orange-500 rounded-full w-full" />
<div class="h-1 bg-orange-400 rounded-full w-2/3 mx-auto" />
<div class="h-1 bg-orange-300 rounded-full w-5/6 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We encourage creative risk-taking, experimentation with new business models, and innovative approaches to both game development and studio management.
</p>
</div>
<!-- Solidarity -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Solidarity
</h3>
<div class="space-y-2">
<div class="h-1 bg-red-500 rounded-full w-full" />
<div class="h-1 bg-red-400 rounded-full w-4/6 mx-auto" />
<div class="h-1 bg-red-300 rounded-full w-3/4 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We stand together in mutual support, recognizing that our individual success is connected to the wellbeing of our entire community.
</p>
</div>
</div>
<!-- Call to Action -->
<div class="mt-16 text-center">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800 max-w-2xl mx-auto">
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">
Join Our Community
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to be part of the cooperative game development movement?
</p>
<UButton
to="/join"
size="lg"
color="primary"
class="px-8"
>
Get Started
</UButton>
</div>
</div>
</UContainer>
</section>
@ -309,4 +281,4 @@
<script setup>
// No specific logic needed for the about page at this time
</script>
</script>

View file

@ -1,322 +1,204 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="Contact"
subtitle="Get in touch with us. We're here to help and answer any questions you might have about Ghost Guild."
theme="blue"
size="large"
/>
<div class="relative">
<!-- Hero Section -->
<section class="mb-24">
<div class="relative">
<h1
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
>
Get in Touch
</h1>
<div class="mb-16">
<p class="text-stone-100 text-lg max-w-md">
We'd be happy to answer any questions<br />
you might have about Ghost Guild
</p>
</div>
</div>
</section>
<!-- Contact Form -->
<section class="py-20 bg-white dark:bg-gray-900">
<UContainer class="max-w-4xl">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
Contact Form
</h2>
<p class="text-gray-600 dark:text-gray-300">
Send us a message and we'll get back to you as soon as possible
</p>
</div>
<section class="mb-32 relative">
<div class="mb-12">
<h2 class="text-3xl font-light text-stone-200 mb-4">
Send us a message (or email hello@ghostguild.org)
</h2>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
<UForm :state="form" class="space-y-6" @submit="handleSubmit">
<!-- Name and Email Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Your Name" name="name" required>
<UInput
v-model="form.name"
placeholder="Enter your full name"
size="xl"
class="w-full"
/>
</UFormField>
<UFormField label="Email Address" name="email" required>
<UInput
v-model="form.email"
type="email"
size="xl"
class="w-full"
placeholder="your.email@example.com"
/>
</UFormField>
</div>
<!-- Subject -->
<UFormField label="Subject" name="subject" required>
<USelect
v-model="form.subject"
:options="subjectOptions"
placeholder="Select a subject"
<div class="border border-ghost-700">
<UForm :state="form" class="space-y-6" @submit="handleSubmit">
<!-- Name and Email Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Your Name" name="name" required>
<UInput
v-model="form.name"
placeholder="Enter your full name"
size="xl"
class="w-full"
/>
</UFormField>
<!-- Message -->
<UFormField label="Message" name="message" required>
<UTextarea
v-model="form.message"
placeholder="Tell us how we can help you..."
:rows="6"
<UFormField label="Email Address" name="email" required>
<UInput
v-model="form.email"
type="email"
size="xl"
class="w-full"
placeholder="your.email@example.com"
/>
</UFormField>
<!-- Additional Options -->
<div class="flex items-center gap-4 pt-2">
<UCheckbox
id="newsletter"
v-model="form.newsletter"
label="Subscribe to our newsletter"
/>
</div>
<!-- Submit Button -->
<div class="flex justify-center pt-4">
<UButton
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
size="xl"
class="px-12"
>
Send Message
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div v-if="success" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p class="text-green-700 dark:text-green-300 text-center">
Thank you! Your message has been sent successfully. We'll get back to you soon.
</p>
</div>
<div v-if="error" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<p class="text-red-700 dark:text-red-300 text-center">
{{ error }}
</p>
</div>
</div>
</UContainer>
</section>
<!-- Subject -->
<UFormField label="Subject" name="subject" required>
<USelect
v-model="form.subject"
:options="subjectOptions"
placeholder="Select a subject"
size="xl"
class="w-full"
/>
</UFormField>
<!-- Support Information -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
Support
</h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Here are various ways to get help and support.
<!-- Message -->
<UFormField label="Message" name="message" required>
<UTextarea
v-model="form.message"
placeholder="Tell us how we can help you..."
:rows="6"
size="xl"
class="w-full"
/>
</UFormField>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
size="xl"
class="px-12 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Send Message
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div
v-if="success"
class="mt-6 p-4 border border-whisper-600 bg-ghost-900/50"
>
<p class="text-whisper-300 text-center">
Thank you! Your message has been sent successfully. We'll get back
to you soon.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Help Center -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<span class="text-white text-sm font-bold">?</span>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Help Center
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-300 rounded-full w-4/5 mx-auto" />
<div class="h-1 bg-blue-200 rounded-full w-3/5 mx-auto" />
<div class="h-1 bg-blue-100 rounded-full w-2/5 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Browse our comprehensive help articles.
</p>
<UButton variant="outline" color="blue" size="sm">
Visit Help Center
</UButton>
</div>
<!-- Community Support -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Community Support
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-emerald-200 rounded-full w-4/6 mx-auto" />
<div class="h-1 bg-emerald-100 rounded-full w-3/6 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Connect with other members in our community.
</p>
<UButton variant="outline" color="emerald" size="sm">
Join Discord
</UButton>
</div>
<!-- Direct Support -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center md:col-span-2 lg:col-span-1">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
<span class="text-white text-xs"></span>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Direct Support
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-purple-500 rounded-full w-full" />
<div class="h-1 bg-purple-300 rounded-full w-3/4 mx-auto" />
<div class="h-1 bg-purple-200 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-purple-100 rounded-full w-1/2 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Get personalized help from our team.
</p>
<UButton variant="outline" color="purple" size="sm">
Email Us
</UButton>
</div>
</div>
<!-- Quick Contact Info -->
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h4>
<p class="text-gray-600 dark:text-gray-400">hello@ghostguild.org</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Response Time</h4>
<p class="text-gray-600 dark:text-gray-400">Usually within 24 hours</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Best For</h4>
<p class="text-gray-600 dark:text-gray-400">General inquiries & support</p>
</div>
</div>
</UContainer>
</section>
<!-- Send Message CTA -->
<section class="py-20 bg-blue-50 dark:bg-blue-900/20">
<UContainer>
<div class="text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Send Message
</h2>
<div class="space-y-6 mb-8">
<div class="flex justify-center">
<div class="h-2 bg-blue-500 rounded-full w-64" />
</div>
<div class="flex justify-center">
<div class="h-2 bg-blue-300 rounded-full w-48" />
</div>
<div class="flex justify-center">
<div class="h-2 bg-blue-500 rounded-full w-32" />
</div>
</div>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to get in touch? We're here to help.
<div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900">
<p class="text-stone-300 text-center">
{{ error }}
</p>
<UButton
@click="scrollToForm"
size="xl"
color="primary"
class="px-12 py-4"
>
Contact Us Now
</UButton>
</div>
</UContainer>
</div>
</section>
</div>
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { reactive, ref, computed } from "vue";
// Form state
const form = reactive({
name: '',
email: '',
subject: '',
message: '',
newsletter: false
})
name: "",
email: "",
subject: "",
message: "",
newsletter: false,
});
// UI state
const isSubmitting = ref(false)
const error = ref('')
const success = ref(false)
const isSubmitting = ref(false);
const error = ref("");
const success = ref(false);
// Subject options
const subjectOptions = [
{ label: 'General Inquiry', value: 'general' },
{ label: 'Membership Questions', value: 'membership' },
{ label: 'Technical Support', value: 'technical' },
{ label: 'Partnership Opportunities', value: 'partnership' },
{ label: 'Press & Media', value: 'media' },
{ label: 'Other', value: 'other' }
]
{ label: "General Inquiry", value: "general" },
{ label: "Membership Questions", value: "membership" },
{ label: "Technical Support", value: "technical" },
{ label: "Partnership Opportunities", value: "partnership" },
{ label: "Press & Media", value: "media" },
{ label: "Other", value: "other" },
];
// Support options
const supportOptions = [
{
title: "Help Center",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Browse our comprehensive help articles.",
details: ["Documentation", "FAQs", "Tutorials"],
},
{
title: "Community Support",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Connect with other members in our community.",
details: ["Discord", "Forums", "Slack"],
},
{
title: "Direct Support",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Get personalized help from our team.",
details: ["Email", "Priority response", "One-on-one assistance"],
},
];
// Form validation
const isFormValid = computed(() => {
return form.name && form.email && form.subject && form.message && form.message.length >= 10
})
return (
form.name &&
form.email &&
form.subject &&
form.message &&
form.message.length >= 10
);
});
// Form submission
const handleSubmit = async () => {
if (isSubmitting.value) return
if (isSubmitting.value) return;
isSubmitting.value = true
error.value = ''
success.value = false
isSubmitting.value = true;
error.value = "";
success.value = false;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500))
await new Promise((resolve) => setTimeout(resolve, 1500));
// For now, just show success message
success.value = true
success.value = true;
// Reset form after success
setTimeout(() => {
Object.assign(form, {
name: '',
email: '',
subject: '',
message: '',
newsletter: false
})
success.value = false
}, 5000)
name: "",
email: "",
subject: "",
message: "",
newsletter: false,
});
success.value = false;
}, 5000);
} catch (err) {
console.error('Contact form error:', err)
error.value = 'Sorry, there was an error sending your message. Please try again or contact us directly at hello@ghostguild.org.'
console.error("Contact form error:", err);
error.value =
"Sorry, there was an error sending your message. Please try again or contact us directly at hello@ghostguild.org.";
} finally {
isSubmitting.value = false
isSubmitting.value = false;
}
}
// Scroll to form function
const scrollToForm = () => {
const formSection = document.querySelector('form')
if (formSection) {
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
</script>
};
</script>

View file

@ -1,83 +1,105 @@
<template>
<div v-if="pending" class="min-h-screen flex items-center justify-center">
<div
v-if="pending"
class="min-h-screen bg-stone-900 flex items-center justify-center"
>
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading event details...</p>
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading event details...</p>
</div>
</div>
<div v-else-if="error" class="min-h-screen flex items-center justify-center">
<div
v-else-if="error"
class="min-h-screen bg-stone-900 flex items-center justify-center"
>
<div class="text-center">
<Icon name="heroicons:exclamation-triangle" class="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Event Not Found</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">The event you're looking for doesn't exist.</p>
<NuxtLink to="/events" class="text-blue-600 dark:text-blue-400 hover:underline">
<Icon
name="heroicons:exclamation-triangle"
class="w-16 h-16 text-red-500 mx-auto mb-4"
/>
<h2 class="text-2xl font-bold text-stone-100 mb-2">Event Not Found</h2>
<p class="text-stone-300 mb-6">
The event you're looking for doesn't exist.
</p>
<NuxtLink to="/events" class="text-blue-400 hover:underline">
Back to Events
</NuxtLink>
</div>
</div>
<div v-else>
<!-- Feature Image Header -->
<div v-if="event.featureImage && (event.featureImage.publicId || event.featureImage.url)" class="relative h-96 overflow-hidden">
<img
<div
v-if="
event.featureImage &&
(event.featureImage.publicId || event.featureImage.url)
"
class="relative h-96 overflow-hidden"
>
<img
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<div class="absolute inset-0" style="background-color: rgba(0, 0, 0, 0.4);"></div>
<div
class="absolute inset-0"
style="background-color: rgba(0, 0, 0, 0.4)"
></div>
<div class="absolute inset-0 flex items-center">
<UContainer>
<div class="max-w-4xl">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
{{ event.title }}
</h1>
<p v-if="event.tagline" class="text-xl text-gray-200">
{{ event.tagline }}
</p>
</div>
</UContainer>
</div>
</div>
<!-- Page Header (fallback when no image) -->
<PageHeader
v-else
:title="event.title"
:subtitle="event.tagline"
theme="blue"
size="medium"
/>
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
<!-- Event Details Section -->
<section class="py-16 bg-white dark:bg-gray-900">
<section class="py-16 bg-stone-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Event Meta Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 mb-8 border border-blue-200 dark:border-blue-800">
<div class="bg-stone-800 rounded-xl p-6 mb-8 border border-stone-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-400"
/>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Date</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatDate(event.startDate) }}</p>
<p class="text-sm text-stone-400">Date</p>
<p class="font-semibold text-stone-100">
{{ formatDate(event.startDate) }}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Time</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatTime(event.startDate, event.endDate) }}</p>
<p class="text-sm text-stone-400">Time</p>
<p class="font-semibold text-stone-100">
{{ formatTime(event.startDate, event.endDate) }}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Location</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ event.location }}</p>
<p class="text-sm text-stone-400">Location</p>
<p class="font-semibold text-stone-100">
{{ event.location }}
</p>
</div>
</div>
</div>
@ -85,16 +107,22 @@
<!-- Event Cancelled Notice -->
<div v-if="event.isCancelled" class="mb-8">
<div class="p-6 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800">
<div class="p-6 bg-red-900/20 rounded-xl border border-red-800">
<div class="flex items-start">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-red-600 dark:text-red-400 mr-3 mt-0.5" />
<Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-red-400 mr-3 mt-0.5"
/>
<div>
<h3 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Event Cancelled</h3>
<p class="text-red-600 dark:text-red-400" v-if="event.cancellationMessage">
<h3 class="text-lg font-semibold text-red-300 mb-2">
Event Cancelled
</h3>
<p class="text-red-400" v-if="event.cancellationMessage">
{{ event.cancellationMessage }}
</p>
<p class="text-red-600 dark:text-red-400" v-else>
This event has been cancelled. We apologize for any inconvenience.
<p class="text-red-400" v-else>
This event has been cancelled. We apologize for any
inconvenience.
</p>
</div>
</div>
@ -103,24 +131,36 @@
<!-- Member-Only Badge -->
<div v-if="event.membersOnly" class="mb-8">
<div class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Icon name="heroicons:lock-closed" class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2" />
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
<div
class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full"
>
<Icon
name="heroicons:lock-closed"
class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2"
/>
<span
class="text-sm font-medium text-purple-700 dark:text-purple-300"
>
Members Only Event - Open to all circles and contribution levels
</span>
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="mb-8">
<div
v-if="event.targetCircles && event.targetCircles.length > 0"
class="mb-8"
>
<div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Recommended for:</span>
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
<span class="text-sm font-medium text-stone-200"
>Recommended for:</span
>
<div class="flex flex-wrap gap-2">
<span
v-for="circle in event.targetCircles"
:key="circle"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
<span
v-for="circle in event.targetCircles"
:key="circle"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-900/30 text-blue-400"
>
{{ formatCircleName(circle) }}
</span>
@ -130,32 +170,64 @@
<!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">About This Event</h2>
<p class="text-gray-700 dark:text-gray-300">{{ event.description }}</p>
<h2 class="text-2xl font-bold text-stone-100 mb-4">
About This Event
</h2>
<p class="text-stone-200">
{{ event.description }}
</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Event Agenda</h3>
<h3 class="text-xl font-semibold text-stone-100 mb-4">
Event Agenda
</h3>
<ul class="space-y-3">
<li v-for="(item, index) in event.agenda" :key="index" class="flex items-start">
<span class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5">
<li
v-for="(item, index) in event.agenda"
:key="index"
class="flex items-start"
>
<span
class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5"
>
{{ index + 1 }}
</span>
<span class="text-gray-700 dark:text-gray-300">{{ item }}</span>
<span class="text-stone-200">{{ item }}</span>
</li>
</ul>
</div>
<div v-if="event.speakers && event.speakers.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Speakers</h3>
<div
v-if="event.speakers && event.speakers.length > 0"
class="mt-8"
>
<h3 class="text-xl font-semibold text-stone-100 mb-4">
Speakers
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="speaker in event.speakers" :key="speaker.name" class="flex items-start space-x-4">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon name="heroicons:user" class="w-8 h-8 text-gray-400 dark:text-gray-500" />
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="flex items-start space-x-4"
>
<div
class="w-16 h-16 bg-stone-700 rounded-full flex items-center justify-center"
>
<Icon
name="heroicons:user"
class="w-8 h-8 text-stone-500"
/>
</div>
<div>
<p class="font-semibold text-gray-900 dark:text-white">{{ speaker.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ speaker.role }}</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ speaker.bio }}</p>
<p class="font-semibold text-stone-100">
{{ speaker.name }}
</p>
<p class="text-sm text-stone-300">
{{ speaker.role }}
</p>
<p class="text-sm text-stone-400 mt-1">
{{ speaker.bio }}
</p>
</div>
</div>
</div>
@ -163,30 +235,75 @@
</div>
<!-- Registration Section -->
<div v-if="!event.isCancelled" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-8 border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">Register for This Event</h3>
<div
v-if="!event.isCancelled"
class="bg-stone-800 rounded-xl p-8 border border-stone-700"
>
<h3 class="text-xl font-bold text-stone-100 mb-6">
Register for This Event
</h3>
<!-- Registration Status -->
<div v-if="registrationStatus === 'registered'" class="mb-6">
<div class="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Icon name="heroicons:check-circle" class="w-6 h-6 text-green-600 dark:text-green-400 mr-3" />
<div>
<p class="font-semibold text-green-700 dark:text-green-300">You're registered!</p>
<p class="text-sm text-green-600 dark:text-green-400">We've sent a confirmation to your email</p>
<div
class="p-4 bg-green-900/20 rounded-lg border border-green-800"
>
<div class="flex items-start justify-between">
<div class="flex items-center">
<Icon
name="heroicons:check-circle"
class="w-6 h-6 text-green-400 mr-3"
/>
<div>
<p class="font-semibold text-green-300">
You're registered!
</p>
<p class="text-sm text-green-400">
We've sent a confirmation to your email
</p>
</div>
</div>
<UButton
color="red"
variant="ghost"
size="sm"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Member Gate Warning -->
<div v-if="event.membersOnly && !isMember && registrationStatus !== 'registered'" class="mb-6">
<div class="flex items-start p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-amber-600 dark:text-amber-400 mr-3 mt-0.5" />
<div
v-if="
event.membersOnly &&
!isMember &&
registrationStatus !== 'registered'
"
class="mb-6"
>
<div
class="flex items-start p-4 bg-amber-900/20 rounded-lg border border-amber-800"
>
<Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-amber-400 mr-3 mt-0.5"
/>
<div>
<p class="font-semibold text-amber-700 dark:text-amber-300">Membership Required</p>
<p class="text-sm text-amber-600 dark:text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any circle to gain access.
<p class="font-semibold text-amber-300">
Membership Required
</p>
<NuxtLink to="/join" class="inline-flex items-center text-sm font-medium text-amber-700 dark:text-amber-300 hover:underline mt-2">
<p class="text-sm text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
<NuxtLink
to="/join"
class="inline-flex items-center text-sm font-medium text-amber-300 hover:underline mt-2"
>
Become a member
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</NuxtLink>
@ -195,40 +312,53 @@
</div>
<!-- Registration Form -->
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4">
<form
v-if="registrationStatus !== 'registered'"
@submit.prevent="handleRegistration"
class="space-y-4"
>
<!-- Show form fields only for public events OR for logged-in members -->
<template v-if="!event.membersOnly || isMember">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label
for="name"
class="block text-sm font-medium text-stone-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label
for="email"
class="block text-sm font-medium text-stone-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label
for="membershipLevel"
class="block text-sm font-medium text-stone-200 mb-2"
>
Membership Status
</label>
<USelect
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
@ -236,28 +366,19 @@
</div>
</template>
<div class="pt-4">
<UButton
<UButton
v-if="!event.membersOnly || isMember"
type="submit"
color="primary"
size="lg"
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
{{ isRegistering ? "Registering..." : "Register for Event" }}
</UButton>
<NuxtLink
v-else
to="/join"
class="block"
>
<UButton
color="primary"
size="lg"
block
>
<NuxtLink v-else to="/join" class="block">
<UButton color="primary" size="lg" block>
Become a Member to Register
</UButton>
</NuxtLink>
@ -265,15 +386,20 @@
</form>
<!-- Event Capacity -->
<div v-if="event.maxAttendees" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-stone-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Event Capacity</span>
<span class="text-sm text-stone-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
<span class="text-sm font-semibold text-stone-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
<div
class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/>
@ -284,12 +410,15 @@
</div>
<!-- Additional Information -->
<div class="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">Questions?</h4>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
If you have any questions about this event, please reach out to our events team.
<div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700">
<h4 class="font-semibold text-stone-100 mb-3">Questions?</h4>
<p class="text-sm text-stone-200 mb-3">
If you have any questions about this event please drop us a line.
</p>
<a href="mailto:events@ghostguild.org" class="inline-flex items-center text-blue-600 dark:text-blue-400 hover:underline">
<a
href="mailto:events@ghostguild.org"
class="inline-flex items-center text-blue-400 hover:underline"
>
<Icon name="heroicons:envelope" class="w-4 h-4 mr-2" />
events@ghostguild.org
</a>
@ -301,164 +430,254 @@
</template>
<script setup>
const route = useRoute()
const toast = useToast()
const route = useRoute();
const toast = useToast();
// Fetch event data from API
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.id}`);
// Handle event not found
if (error.value?.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
statusMessage: "Event not found",
});
}
// Authentication
const { isMember, memberData, checkMemberStatus } = useAuth()
const { isMember, memberData, checkMemberStatus } = useAuth();
// Check member status on mount
onMounted(async () => {
await checkMemberStatus()
await checkMemberStatus();
// Pre-fill form if member is logged in
if (memberData.value) {
registrationForm.value.name = memberData.value.name
registrationForm.value.email = memberData.value.email
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
registrationForm.value.name = memberData.value.name;
registrationForm.value.email = memberData.value.email;
registrationForm.value.membershipLevel =
memberData.value.membershipLevel || "non-member";
// Check if user is already registered
await checkRegistrationStatus();
}
})
});
// Check if user is already registered for this event
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return;
try {
const response = await $fetch(
`/api/events/${route.params.id}/check-registration`,
{
method: "POST",
body: { email: memberData.value.email },
},
);
if (response.isRegistered) {
registrationStatus.value = "registered";
}
} catch (error) {
console.error("Failed to check registration status:", error);
}
};
// Registration form state
const registrationForm = ref({
name: '',
email: '',
membershipLevel: 'non-member'
})
name: "",
email: "",
membershipLevel: "non-member",
});
const membershipOptions = [
{ label: 'Non-member', value: 'non-member' },
{ label: 'Circle of Community', value: 'community' },
{ label: 'Circle of Founders', value: 'founder' },
{ label: 'Circle of Practitioners', value: 'practitioner' }
]
{ label: "Non-member", value: "non-member" },
{ label: "Circle of Community", value: "community" },
{ label: "Circle of Founders", value: "founder" },
{ label: "Circle of Practitioners", value: "practitioner" },
];
const isRegistering = ref(false)
const registrationStatus = ref('not-registered') // 'not-registered', 'registered'
const isRegistering = ref(false);
const isCancelling = ref(false);
const registrationStatus = ref("not-registered"); // 'not-registered', 'registered'
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
// Format time range for display
const formatTime = (startDate, endDate) => {
const start = new Date(startDate)
const end = new Date(endDate)
const timeFormat = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
})
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`
}
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
// Format circle name for display
const formatCircleName = (circleValue) => {
const circleNames = {
'community': 'Community Circle',
'founder': 'Founder Circle',
'practitioner': 'Practitioner Circle'
}
return circleNames[circleValue] || circleValue
}
community: "Community Circle",
founder: "Founder Circle",
practitioner: "Practitioner Circle",
};
return circleNames[circleValue] || circleValue;
};
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return ''
const config = useRuntimeConfig()
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
}
if (!publicId) return "";
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
};
// Get image URL with fallback logic
const getImageUrl = (featureImage) => {
if (!featureImage) return ''
if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url
return featureImage.url;
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_1200,h_400,c_fill')
return getOptimizedImageUrl(featureImage.publicId, "w_1200,h_400,c_fill");
}
return ''
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src)
console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder
}
};
// Handle registration submission
const handleRegistration = async () => {
isRegistering.value = true
isRegistering.value = true;
try {
// Submit registration to API using slug or ID
const response = await $fetch(`/api/events/${route.params.id}/register`, {
method: 'POST',
body: registrationForm.value
})
method: "POST",
body: registrationForm.value,
});
// Update registration status
registrationStatus.value = 'registered'
registrationStatus.value = "registered";
// Show success toast
toast.add({
title: 'Registration Successful!',
title: "Registration Successful!",
description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
color: 'green'
})
color: "green",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++
event.value.registeredCount++;
}
} catch (error) {
console.error('Registration failed:', error)
console.error("Registration failed:", error);
// Handle specific error messages
const errorMessage = error.data?.statusMessage || 'Something went wrong. Please try again.'
const errorMessage =
error.data?.statusMessage || "Something went wrong. Please try again.";
toast.add({
title: 'Registration Failed',
title: "Registration Failed",
description: errorMessage,
color: 'red'
})
color: "red",
});
} finally {
isRegistering.value = false
isRegistering.value = false;
}
}
};
// Handle registration cancellation
const handleCancelRegistration = async () => {
if (
!confirm(
"Are you sure you want to cancel your registration for this event?",
)
) {
return;
}
isCancelling.value = true;
try {
const response = await $fetch(
`/api/events/${route.params.id}/cancel-registration`,
{
method: "POST",
body: {
email: registrationForm.value.email || memberData.value?.email,
},
},
);
// Update registration status
registrationStatus.value = "not-registered";
// Show success toast
toast.add({
title: "Registration Cancelled",
description: "Your registration has been cancelled successfully.",
color: "blue",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount--;
}
} catch (error) {
console.error("Cancel registration failed:", error);
const errorMessage =
error.data?.statusMessage ||
"Failed to cancel registration. Please try again.";
toast.add({
title: "Cancellation Failed",
description: errorMessage,
color: "red",
});
} finally {
isCancelling.value = false;
}
};
// SEO Meta
useHead(() => ({
title: event.value ? `${event.value.title} - Ghost Guild Events` : 'Event - Ghost Guild',
title: event.value
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [
{ name: 'description', content: event.value?.description || 'View event details and register' }
]
}))
</script>
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>

View file

@ -1,141 +1,219 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
<PageHeader
title="Events"
subtitle="Join our community events, workshops, and gatherings designed to connect developers and share knowledge about cooperative game development."
theme="blue"
subtitle="Join our community events, workshops, and gatherings"
size="large"
/>
<!-- Event Calendar -->
<section class="py-20 bg-white dark:bg-gray-900">
<!-- Events Section with Tabs -->
<section class="py-20 bg-stone-900 dark:bg-stone-950">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Event Calendar
</h2>
<div class="flex items-center justify-center gap-2 mb-8">
<div class="w-6 h-6 bg-blue-500 rounded-full" />
<div class="w-6 h-6 bg-blue-400 rounded-full" />
<div class="w-8 h-1 bg-blue-300 rounded-full" />
<div class="w-8 h-1 bg-blue-200 rounded-full" />
<div class="w-8 h-1 bg-blue-100 rounded-full" />
</div>
</div>
<div class="max-w-5xl mx-auto">
<div class="bg-gray-50 dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<ClientOnly>
<div v-if="pending" class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading events...</p>
</div>
</div>
<VueCal
v-else
:events="events"
:time="false"
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false
}"
@event-click="onEventClick"
/>
<template #fallback>
<div class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading calendar...</p>
<UTabs
v-model="activeTab"
:items="[
{ label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' },
{ label: 'Calendar', value: 'calendar', slot: 'calendar' },
]"
class="max-w-6xl mx-auto"
>
<template #upcoming>
<div class="max-w-4xl mx-auto space-y-6 pt-8">
<NuxtLink
v-for="event in upcomingEvents"
:key="event.id"
:to="`/events/${event.slug || event.id}`"
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
>
<div class="flex-shrink-0 text-center">
<div class="text-2xl font-bold text-stone-100">
{{ event.start.getDate() }}
</div>
<div class="text-xs text-stone-400 uppercase">
{{
event.start.toLocaleDateString("en-US", {
month: "short",
})
}}
</div>
</div>
</template>
</ClientOnly>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1">
<h3
class="text-lg font-semibold text-stone-100 group-hover:text-blue-400 transition-colors"
>
{{ event.title }}
</h3>
<Icon
v-if="event.membersOnly"
name="heroicons:lock-closed"
class="w-4 h-4 text-purple-500 flex-shrink-0 mt-1"
/>
</div>
<p class="text-sm text-stone-300 mb-2 line-clamp-2">
{{ event.content }}
</p>
<div
v-if="event.series?.isSeriesEvent"
class="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400"
>
<div
class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center text-xs font-bold"
>
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<Icon
name="heroicons:arrow-right"
class="w-5 h-5 text-stone-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
</template>
<template #calendar>
<div class="pt-8">
<ClientOnly>
<div
v-if="pending"
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<p class="text-stone-200">Loading events...</p>
</div>
</div>
<VueCal
v-else
:events="events"
:time="false"
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false,
}"
@event-click="onEventClick"
/>
<template #fallback>
<div
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading calendar...</p>
</div>
</div>
</template>
</ClientOnly>
</div>
</template>
</UTabs>
</UContainer>
</section>
<!-- Event Series -->
<section v-if="activeSeries.length > 0" class="py-20 bg-purple-50 dark:bg-purple-900/20">
<section
v-if="activeSeries.length > 0"
class="py-20 bg-stone-800 dark:bg-stone-900"
>
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-8">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
Active Event Series
</h2>
<p class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your knowledge and build community connections.
<p class="text-stone-300 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
>
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700"
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
>
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-gray-500">
<div class="flex items-center gap-1 text-xs text-stone-400">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<h3 class="text-lg font-semibold text-stone-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="event in series.events.slice(0, 3)"
<div
v-for="event in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium">
{{ event.series?.position || '?' }}
<div
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
>
{{ event.series?.position || "?" }}
</div>
<span class="text-gray-600 dark:text-gray-400 truncate">{{ event.title }}</span>
<span class="text-stone-300 truncate">{{ event.title }}</span>
</div>
<span class="text-gray-500 dark:text-gray-500">
<span class="text-stone-400">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div v-if="series.events.length > 3" class="text-xs text-gray-500 dark:text-gray-500 text-center pt-1">
<div
v-if="series.events.length > 3"
class="text-xs text-stone-400 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="text-gray-500 dark:text-gray-500">
<div class="text-stone-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
]">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
@ -144,237 +222,101 @@
</UContainer>
</section>
<!-- Upcoming Events -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Upcoming Events
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<NuxtLink
v-for="event in upcomingEvents"
:key="event.id"
:to="`/events/${event.slug || event.id}`"
class="group bg-white dark:bg-gray-900 rounded-xl overflow-hidden shadow-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 transition-all hover:shadow-xl"
>
<!-- Feature Image -->
<div v-if="event.featureImage?.url" class="aspect-video w-full overflow-hidden">
<img
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
@error="handleImageError"
/>
</div>
<div class="p-6">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="mb-3">
<div class="inline-flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400">
<div class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-bold">
{{ event.series.position }}
</div>
<Icon name="heroicons:squares-2x2" class="w-3 h-3" />
{{ event.series.title }}
</div>
</div>
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
event.class === 'event-community' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
event.class === 'event-workshop' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' :
event.class === 'event-social' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
]">
{{ event.class === 'event-community' ? 'Community' :
event.class === 'event-workshop' ? 'Workshop' :
event.class === 'event-social' ? 'Social' : 'Showcase' }}
</div>
<Icon v-if="event.membersOnly" name="heroicons:lock-closed" class="w-4 h-4 text-purple-500" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ event.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ event.content }}
</p>
<div class="flex items-center text-sm text-gray-500 dark:text-gray-500">
<Icon name="heroicons:calendar" class="w-4 h-4 mr-1" />
{{ formatEventDate(event.start) }}
</div>
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<span class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:translate-x-1 transition-transform">
View Details
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</span>
</div>
</div>
</NuxtLink>
</div>
</UContainer>
</section>
<!-- Attend Our Events -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-stone-800 dark:bg-stone-900">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
Attend Our Events
</h2>
</div>
<div class="max-w-4xl mx-auto">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800 mb-12">
<div class="space-y-6 mb-8">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-2/3" />
</div>
<div
class="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Our events are designed to build community, share knowledge, and support developers exploring cooperative models. From informal networking sessions to structured workshops, there's something for everyone.
<p class="text-lg leading-relaxed text-stone-200 mb-6">
Our events are ,Lorem ipsum, dolor sit amet consectetur
adipisicing elit. Quibusdam exercitationem delectus ab
voluptates aspernatur, quia deleniti aut maxime, veniam
accusantium non dolores saepe error, ipsam laudantium asperiores
dolorum alias nulla!
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Regular events include monthly community meetups, quarterly workshops on cooperative business structures, and seasonal social gatherings. We also host special events featuring guest speakers and collaborative project showcases.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">
All events are welcoming to developers at any stage of their cooperative journey, from those just curious about alternative models to experienced co-op members sharing their insights.
<p class="text-lg leading-relaxed text-stone-200 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-blue-500 rounded" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Monthly Meetups</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-blue-500 rounded-full" />
<div class="h-1 bg-blue-300 rounded-full w-3/4 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Casual networking and knowledge sharing sessions
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Monthly Meetups
</h3>
<p class="text-sm text-stone-300">
Casual knowledge sharing sessions
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Workshops</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-emerald-500 rounded-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Hands-on learning about cooperative business models
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Workshops
</h3>
<p class="text-sm text-stone-300">
Hands-on learning about cooperative and worker-centric business
models
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-purple-500 rounded-full" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Social Events</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-purple-500 rounded-full" />
<div class="h-1 bg-purple-300 rounded-full w-2/3 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Community building and celebration gatherings
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Social Events
</h3>
<p class="text-sm text-stone-300">
Game nights, socials, and more
</p>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Event Highlights -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Event Highlights
</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
<div class="space-y-6">
<div class="space-y-4">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-3/4" />
<div class="h-2 bg-blue-200 rounded-full w-1/2" />
</div>
<div class="space-y-6">
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Recent Highlights
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed mb-4">
Our latest workshop on "Building Sustainable Game Co-ops" brought together 50+ developers to explore practical strategies for transitioning to cooperative models.
</p>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
The quarterly showcase featured three member studios presenting their games and sharing insights about democratic decision-making in creative projects.
</p>
</div>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Upcoming Features
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Next month's event will include a panel discussion on funding cooperative studios, featuring successful co-op founders and supporting investors.
</p>
</div>
</div>
</div>
<div class="flex items-center justify-center">
<div class="w-full max-w-md h-64 bg-blue-100 dark:bg-blue-900/30 rounded-2xl border-2 border-dashed border-blue-300 dark:border-blue-700 flex items-center justify-center">
<div class="text-center">
<div class="w-16 h-16 bg-blue-200 dark:bg-blue-800 rounded-xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-blue-500 rounded" />
</div>
<p class="text-blue-600 dark:text-blue-400 font-medium">Event Photos</p>
<p class="text-sm text-blue-500 dark:text-blue-500 mt-2">Coming Soon</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
import { VueCal } from 'vue-cal'
import 'vue-cal/style.css'
import { VueCal } from "vue-cal";
import "vue-cal/style.css";
// Active tab state
const activeTab = ref("upcoming");
// Fetch events from API
const { data: eventsData, pending, error } = await useFetch('/api/events')
const { data: eventsData, pending, error } = await useFetch("/api/events");
// Fetch series from API
const { data: seriesData } = await useFetch('/api/series')
const { data: seriesData } = await useFetch("/api/series");
// Transform events for calendar display
const events = computed(() => {
if (!eventsData.value) return []
return eventsData.value.map(event => ({
if (!eventsData.value) return [];
return eventsData.value.map((event) => ({
id: event.id || event._id,
slug: event.slug,
start: new Date(event.startDate),
@ -388,117 +330,128 @@ const events = computed(() => {
registeredCount: event.registeredCount,
maxAttendees: event.maxAttendees,
featureImage: event.featureImage,
series: event.series
}))
})
series: event.series,
}));
});
// Get active event series
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value.filter(series =>
series.status === 'active' || series.isOngoing || series.isUpcoming
)
})
if (!seriesData.value) return [];
return seriesData.value.filter(
(series) =>
series.status === "active" || series.isOngoing || series.isUpcoming,
);
});
// Get upcoming events (future events)
const upcomingEvents = computed(() => {
const now = new Date()
const now = new Date();
return events.value
.filter(event => event.start > now)
.filter((event) => event.start > now)
.sort((a, b) => a.start - b.start)
.slice(0, 6) // Show max 6 upcoming events
})
.slice(0, 6); // Show max 6 upcoming events
});
// Format event date for display
const formatEventDate = (date) => {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
};
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return ''
const config = useRuntimeConfig()
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
}
if (!publicId) return "";
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
};
// Get image URL with fallback logic
const getImageUrl = (featureImage) => {
if (!featureImage) return ''
if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url
return featureImage.url;
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_400,h_200,c_fill')
return getOptimizedImageUrl(featureImage.publicId, "w_400,h_200,c_fill");
}
return ''
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src)
console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder
}
};
// Handle calendar event click
const onEventClick = (event) => {
if (event.id) {
navigateTo(`/events/${event.slug || event.id}`)
navigateTo(`/events/${event.slug || event.id}`);
}
}
};
// Series helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
tournament: "Tournament",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
'recurring_meetup': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'multi_day': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'course': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
'tournament': 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
return classes[type] || 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
workshop_series:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
recurring_meetup:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
multi_day:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
course:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
tournament: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
classes[type] ||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
);
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const startMonth = start.toLocaleDateString('en-US', { month: 'short' })
const endMonth = end.toLocaleDateString('en-US', { month: 'short' })
const startDay = start.getDate()
const endDay = end.getDate()
const year = end.getFullYear()
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay}-${endDay}, ${year}`
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
}
}
};
</script>
<style scoped>
@ -511,19 +464,127 @@ const formatDateRange = (startDate, endDate) => {
/* Custom calendar styling to match the site theme */
.custom-calendar {
--vuecal-primary-color: #3b82f6;
--vuecal-text-color: #374151;
--vuecal-border-color: #e5e7eb;
--vuecal-header-color: #f9fafb;
--vuecal-today-color: #dbeafe;
--vuecal-primary-color: #fff;
--vuecal-text-color: #e7e5e4;
--vuecal-border-color: #57534e;
--vuecal-header-color: #1c1917;
--vuecal-today-color: #292524;
background-color: #292524;
}
.dark .custom-calendar {
--vuecal-primary-color: #60a5fa;
--vuecal-text-color: #d1d5db;
--vuecal-border-color: #4b5563;
--vuecal-header-color: #374151;
--vuecal-today-color: #1e3a8a;
.custom-calendar :deep(.vuecal__bg) {
background-color: #292524;
}
.custom-calendar :deep(.vuecal__header) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__title-bar) {
background-color: #1c1917;
}
.custom-calendar :deep(.vuecal__title) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__weekdays-headings) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__heading) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__cell) {
background-color: #292524;
border-color: #57534e;
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell-content) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell--today) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell--out-of-scope) {
background-color: #1c1917;
color: #78716c;
}
.custom-calendar :deep(.vuecal__arrow) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__arrow:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__today-btn) {
background-color: #44403c;
color: white;
border: 1px solid #78716c;
}
.custom-calendar :deep(.vuecal__today-btn:hover) {
background-color: #57534e;
border-color: #a8a29e;
}
.custom-calendar :deep(.vuecal__view-btn),
.custom-calendar :deep(button[class*="view"]) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn:hover),
.custom-calendar :deep(button[class*="view"]:hover) {
background-color: #57534e !important;
border-color: #a8a29e !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__view-btn--active),
.custom-calendar :deep(button[class*="view"][class*="active"]) {
background-color: #0c0a09 !important;
color: #ffffff !important;
border-color: #a8a29e !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn--active:hover),
.custom-calendar :deep(button[class*="view"][class*="active"]:hover) {
background-color: #1c1917 !important;
border-color: #d6d3d1 !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__title-bar button) {
color: #ffffff !important;
font-weight: 600 !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn.active) {
background-color: #0c0a09 !important;
border-color: #a8a29e !important;
}
/* Event type styling */
@ -562,4 +623,4 @@ const formatDateRange = (startDate, endDate) => {
color: var(--vuecal-primary-color);
font-weight: 600;
}
</style>
</style>

View file

@ -1,104 +1,114 @@
<template>
<div>
<!-- Hero Section -->
<PageHeader
title="Discover Ghost Guild"
subtitle="A community for game developers exploring cooperative models"
theme="blue"
size="hero"
:show-interactive-area="true"
interactive-content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
/>
<div class="relative">
<!-- Experimental Hero Section -->
<section class="mb-24">
<div class="relative">
<!-- Large artistic title -->
<h1
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
>
Become a Ghostie
</h1>
<!-- Join Us Today -->
<section class="py-20 bg-white dark:bg-gray-900">
<UContainer>
<div class="text-center max-w-3xl mx-auto">
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-12">
Join Us Today
</h2>
<div class="space-y-6">
<div class="h-2 bg-blue-500 rounded-full mx-auto max-w-sm" />
<div class="h-12 bg-blue-500 rounded-xl mx-auto max-w-xs flex items-center justify-center">
<UButton to="/join" size="lg" color="primary" class="text-white font-semibold">
Get Started
</UButton>
<!-- Floating subtitle -->
<div class="mb-16">
<p class="text-stone-100 text-lg max-w-md">
A community for creatives and game devs<br />
exploring cooperative models
</p>
</div>
<!-- Decorative elements -->
<div
class="absolute top-0 right-0 w-32 h-32 border border-ghost-800 rounded-full opacity-20"
/>
<div
class="absolute top-20 -left-8 w-16 h-px bg-whisper-500 opacity-40"
/>
</div>
</section>
<!-- Join Section - Offset Layout -->
<section class="mb-32 relative">
<div>
<NuxtLink
to="/join"
class="inline-block px-8 py-3 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Join Us Today
</NuxtLink>
</div>
<!-- Decorative corner element -->
<div
class="absolute -right-4 top-0 w-20 h-20 border-t border-r border-ghost-800 opacity-30"
/>
</section>
<!-- Circles - Asymmetrical Grid -->
<section class="mb-32">
<div class="space-y-8">
<div
v-for="(circle, index) in circles"
:key="circle.value"
class="flex gap-8 items-start"
>
<!-- Content -->
<div class="flex-1 max-w-lg">
<h3 class="text-xl text-stone-100 mb-3">{{ circle.label }}</h3>
<p class="text-stone-200 text-sm leading-relaxed mb-4">
{{ circle.description }}
</p>
<!-- Features as inline text -->
<div class="text-sm text-stone-400">
<span v-for="(feature, i) in circle.features" :key="feature">
{{ feature
}}<span v-if="i < circle.features.length - 1"> </span>
</span>
</div>
</div>
<!-- Side accent -->
</div>
</UContainer>
</div>
</section>
<!-- About Our Circles -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-8">
About Our Circles
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<UCard v-for="circle in circles" :key="circle.value" class="h-full">
<template #header>
<div class="flex items-center gap-4 mb-4">
<div v-if="circle.value === 'community'" class="w-8 h-8 bg-blue-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
<div v-else-if="circle.value === 'founder'" class="w-8 h-8 bg-blue-500 flex items-center justify-center">
<div class="w-4 h-1 bg-white" />
<div class="w-1 h-4 bg-white absolute" />
</div>
<div v-else class="w-8 h-8 bg-blue-500 rounded" />
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ circle.label }}</h3>
</div>
</template>
<div class="space-y-4">
<p class="text-gray-600 dark:text-gray-300">{{ circle.description }}</p>
<div class="space-y-2">
<div class="h-1 bg-blue-500 rounded-full" />
<div class="h-1 bg-blue-300 rounded-full" />
<div class="h-1 bg-blue-200 rounded-full" />
</div>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li v-for="feature in circle.features" :key="feature" class="flex items-start">
<span class="mr-2"></span>
<span>{{ feature }}</span>
</li>
</ul>
</div>
</UCard>
</div>
</UContainer>
</section>
<!-- Why Join? - Diagonal Layout -->
<section class="mb-32 relative">
<div class="transform -rotate-1">
<h2 class="text-3xl font-light text-stone-200 mb-12">Why Join?</h2>
</div>
<!-- Why Join? -->
<section class="py-20 bg-white dark:bg-gray-900">
<UContainer>
<div class="text-center max-w-4xl mx-auto">
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-12">
Why Join?
</h2>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-12 border border-blue-200 dark:border-blue-800">
<div class="max-w-2xl mx-auto">
<div class="h-2 bg-blue-500 rounded-full mb-8" />
<p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</div>
</div>
</div>
</UContainer>
</section>
<div class="ml-12 relative">
<div
class="absolute -left-4 top-0 w-32 h-px bg-whisper-500 opacity-30 transform rotate-12"
/>
<div class="max-w-2xl">
<p class="text-stone-300 leading-loose text-lg mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<p class="text-stone-400 leading-relaxed ml-8">
Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua.<br />
Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
<div
class="absolute -bottom-8 right-0 text-6xl text-stone-800 opacity-20 font-bold"
>
?
</div>
</div>
</section>
</div>
</template>
<script setup>
import { getCircleOptions } from '~/config/circles'
import { getCircleOptions } from "~/config/circles";
const circles = getCircleOptions()
</script>
const circles = getCircleOptions();
</script>

File diff suppressed because it is too large Load diff

View file

@ -1,161 +1,639 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
<PageHeader
title="Member Dashboard"
:subtitle="`Welcome back, ${memberData?.name || 'Member'}!`"
theme="blue"
size="medium"
/>
<section class="py-12 bg-white dark:bg-gray-900">
<UContainer>
<div v-if="!memberData || authPending" class="flex justify-center items-center py-20">
<div class="text-center">
<div class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading your dashboard...</p>
</div>
<UContainer class="py-12">
<!-- Loading State -->
<div
v-if="!memberData || authPending"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-300">Loading your dashboard...</p>
</div>
</div>
<div v-else class="space-y-8">
<!-- Welcome Section -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
<!-- Dashboard Content -->
<div v-else class="space-y-8">
<!-- Welcome Card -->
<UCard
class="sparkle-field"
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h1 class="text-2xl font-bold text-stone-100 ethereal-text">
Welcome to Ghost Guild, {{ memberData?.name }}!
</h1>
<p class="text-gray-600 dark:text-gray-300 mb-4">
Your membership is active and you're part of our cooperative community.
<p class="text-stone-300 mt-2">
Your membership is active and you're part of our cooperative
community.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 py-2 border">
<span class="text-gray-500">Circle:</span>
<span class="font-medium text-blue-600 dark:text-blue-400 ml-1 capitalize">{{ memberData?.circle }}</span>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 py-2 border">
<span class="text-gray-500">Contribution:</span>
<span class="font-medium text-green-600 dark:text-green-400 ml-1">${{ memberData?.contributionTier }} CAD/month</span>
</div>
</div>
</div>
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
<div class="flex-shrink-0" v-if="memberData?.avatar">
<img
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name"
class="w-16 h-16"
/>
</div>
<div v-else class="flex-shrink-0">
<div
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 font-bold text-xl"
>
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
</div>
</div>
</div>
</div>
</template>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div class="flex flex-wrap gap-4 text-sm">
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-stone-400">Circle:</span>
<span class="font-medium text-whisper-300 ml-1 capitalize">{{
memberData?.circle
}}</span>
</div>
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-stone-400">Contribution:</span>
<span class="font-medium text-whisper-300 ml-1"
>${{ memberData?.contributionTier }} CAD/month</span
>
</div>
</div>
</UCard>
<!-- Quick Links -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Quick Links
</h2>
</template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<UButton
to="/member/my-updates"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
<template #leading>
<Icon name="heroicons:pencil-square" class="w-5 h-5" />
</template>
Post an Update
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:calendar-days" class="w-5 h-5" />
</template>
Propose an Event
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:user-group" class="w-5 h-5" />
</template>
Book a Peer Session
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:book-open" class="w-5 h-5" />
</template>
Browse Resources
</UButton>
<UButton
to="/member/profile"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
<template #leading>
<Icon name="heroicons:user-circle" class="w-5 h-5" />
</template>
Update Profile
</UButton>
</div>
</UCard>
<!-- Quick Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
class="hover:border-whisper-600 transition-colors"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-whisper-400"
/>
</div>
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
Upcoming Events
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Discover and register for community events and workshops.
</p>
<UButton to="/events" variant="outline" size="sm">
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
Upcoming Events
</h3>
<p class="text-stone-300 mb-4">
Discover and register for community events and workshops.
</p>
<template #footer>
<UButton
to="/events"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
View Events
</UButton>
</div>
</template>
</UCard>
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:user-group"
class="w-6 h-6 text-whisper-400"
/>
</div>
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
Community
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Connect with other members in your circle and beyond.
</p>
<UButton to="/members" variant="outline" size="sm">
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">Community</h3>
<p class="text-stone-300 mb-4">
Connect with other members in your circle and beyond.
</p>
<template #footer>
<UButton
to="/members"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Members
</UButton>
</div>
</template>
</UCard>
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:cog-6-tooth"
class="w-6 h-6 text-whisper-400"
/>
</div>
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
Account Settings
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Manage your profile and membership settings.
</p>
<UButton variant="outline" size="sm" disabled>
Coming Soon
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
Account Settings
</h3>
<p class="text-stone-300 mb-4">
Manage your profile and membership settings.
</p>
<template #footer>
<UButton
to="/member/profile#account"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Manage Account
</UButton>
</template>
</UCard>
</div>
<!-- Your Registered Events -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Your Upcoming Events
</h2>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-stone-300 hover:text-stone-100"
>
Browse All Events
</UButton>
</div>
</template>
<div v-if="loadingEvents" class="text-center py-8">
<div
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
<!-- Recent Activity (Placeholder) -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold mb-6 text-gray-900 dark:text-white">
Recent Activity
</h2>
<div class="text-center py-12">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No recent activity
</h3>
<p class="text-gray-500 dark:text-gray-400">
Your activity and event history will appear here as you participate in the community.
</p>
<div v-else-if="registeredEvents.length" class="space-y-4">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="block p-4 border border-ghost-700 hover:border-whisper-500 transition-colors"
>
<div class="flex items-start gap-4">
<div
v-if="
evt.featureImage &&
(evt.featureImage.publicId || evt.featureImage.url)
"
class="flex-shrink-0 w-20 h-20 overflow-hidden"
>
<img
:src="getEventImageUrl(evt.featureImage)"
:alt="evt.title"
class="w-full h-full object-cover"
/>
</div>
<div
v-else
class="flex-shrink-0 w-20 h-20 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-8 h-8 text-whisper-400"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-stone-100 mb-1">
{{ evt.title }}
</h3>
<div class="flex items-center gap-4 text-sm text-stone-400">
<span class="flex items-center gap-1">
<Icon name="heroicons:calendar" class="w-4 h-4" />
{{ formatEventDate(evt.startDate) }}
</span>
<span class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(evt.startDate) }}
</span>
</div>
</div>
<div class="flex-shrink-0">
<Icon
name="heroicons:chevron-right"
class="w-5 h-5 text-stone-500"
/>
</div>
</div>
</NuxtLink>
</div>
<div v-else class="text-center py-8">
<Icon
name="heroicons:calendar-days"
class="w-12 h-12 text-stone-600 mx-auto mb-3"
/>
<p class="text-stone-400 mb-4">
You haven't registered for any upcoming events
</p>
<UButton
to="/events"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Events
</UButton>
</div>
</UCard>
<!-- Community Pulse - Recent Updates -->
<UCard
class="sparkle-field"
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Community Pulse
</h2>
<UButton
to="/updates"
variant="ghost"
size="sm"
class="text-stone-300 hover:text-stone-100"
>
View All
</UButton>
</div>
</template>
<div v-if="loadingUpdates" class="text-center py-8">
<div
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
<div v-else-if="recentUpdates.length" class="space-y-4">
<div
v-for="update in recentUpdates"
:key="update._id"
class="border-l-2 border-ghost-600 pl-4 py-2"
>
<div class="flex items-start gap-3">
<img
v-if="
update.author?.avatar && isValidAvatar(update.author.avatar)
"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-8 h-8 flex-shrink-0"
/>
<div
v-else-if="update.author?.name"
class="w-8 h-8 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 text-xs font-bold flex-shrink-0"
>
{{ update.author.name.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<span class="font-semibold text-stone-100 text-sm">
{{ update.author?.name }}
</span>
<span class="text-xs text-stone-500">
{{ formatTimeAgo(update.createdAt) }}
</span>
</div>
<p class="text-stone-300 text-sm line-clamp-2">
{{ update.content }}
</p>
<NuxtLink
:to="`/updates/${update._id}`"
class="text-xs text-whisper-400 hover:text-whisper-300 mt-1 inline-block"
>
Read more
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</UContainer>
</section>
<div v-else class="text-center py-8">
<p class="text-stone-400 mb-4">No community updates yet</p>
<UButton
to="/updates/new"
size="sm"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Post the First Update
</UButton>
</div>
</UCard>
</div>
</UContainer>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth()
const { memberData, checkMemberStatus } = useAuth();
const recentUpdates = ref([]);
const loadingUpdates = ref(false);
const registeredEvents = ref([]);
const loadingEvents = ref(false);
// Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData('dashboard-auth', async () => {
// Only check authentication on client side
if (process.server) return null
console.log('📊 Dashboard auth check - memberData exists:', !!memberData.value)
// If no member data, try to authenticate
if (!memberData.value) {
console.log(' - No member data, checking authentication...')
const isAuthenticated = await checkMemberStatus()
console.log(' - Auth result:', isAuthenticated)
if (!isAuthenticated) {
console.log(' - Redirecting to login')
await navigateTo('/login')
return null
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
// Only check authentication on client side
if (process.server) return null;
console.log(
"📊 Dashboard auth check - memberData exists:",
!!memberData.value,
);
// If no member data, try to authenticate
if (!memberData.value) {
console.log(" - No member data, checking authentication...");
const isAuthenticated = await checkMemberStatus();
console.log(" - Auth result:", isAuthenticated);
if (!isAuthenticated) {
console.log(" - Redirecting to login");
await navigateTo("/login");
return null;
}
}
console.log(" - ✅ Dashboard auth successful");
return memberData.value;
},
);
// Load recent updates
const loadRecentUpdates = async () => {
loadingUpdates.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 5, skip: 0 },
});
recentUpdates.value = response.updates;
} catch (error) {
console.error("Failed to load recent updates:", error);
} finally {
loadingUpdates.value = false;
}
console.log(' - ✅ Dashboard auth successful')
return memberData.value
})
};
// Load registered events
const loadRegisteredEvents = async () => {
console.log(
"🔍 memberData.value:",
JSON.stringify(memberData.value, null, 2),
);
console.log("🔍 memberData.value._id:", memberData.value?._id);
console.log("🔍 memberData.value.id:", memberData.value?.id);
const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) {
console.log("❌ No member ID available");
return;
}
console.log("📅 Loading events for member:", memberId);
loadingEvents.value = true;
try {
const response = await $fetch("/api/members/my-events", {
params: { memberId },
});
console.log("📅 Events response:", response);
registeredEvents.value = response.events;
} catch (error) {
console.error("Failed to load registered events:", error);
} finally {
loadingEvents.value = false;
}
};
// Valid ghost avatar options
const validAvatars = [
"disbelieving",
"double-take",
"exasperated",
"mild",
"sweet",
];
const isValidAvatar = (avatar) => {
if (!avatar) return false;
return validAvatars.includes(avatar.toLowerCase());
};
const capitalize = (str) => {
if (!str) return "";
// Handle kebab-case or multi-word avatars (e.g., "double-take" -> "Double-Take")
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("-");
};
const formatTimeAgo = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 604800)
return `${Math.floor(diffInSeconds / 86400)}d ago`;
return updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
// Helper functions for event display
const getEventImageUrl = (featureImage) => {
if (!featureImage) return "";
if (featureImage.url) {
return featureImage.url;
}
if (featureImage.publicId) {
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_200,h_200,c_fill,f_auto,q_auto/${featureImage.publicId}`;
}
return "";
};
const formatEventDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
};
const formatEventTime = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(date);
};
onMounted(() => {
loadRecentUpdates();
loadRegisteredEvents();
});
// Set page meta
useHead({
title: 'Member Dashboard - Ghost Guild'
})
title: "Member Dashboard - Ghost Guild",
});
// Removed middleware - handling auth directly in the page component
</script>
</script>

View file

@ -0,0 +1,211 @@
<template>
<div>
<PageHeader
title="My Updates"
subtitle="View and manage your updates"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Stats -->
<div v-if="!pending" class="mb-8 flex items-center justify-between">
<div class="text-stone-300">
<span class="text-2xl font-bold text-stone-100">{{ total }}</span>
{{ total === 1 ? "update" : "updates" }} posted
</div>
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
</div>
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your updates...</p>
</div>
</div>
<!-- Updates List -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
Share your first update with the community
</p>
<UButton to="/updates/new" icon="i-lucide-plus">
Post Your First Update
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const { isAuthenticated, checkMemberStatus } = useAuth();
const updates = ref([]);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
return;
}
}
await loadUpdates();
});
// Load updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch("/api/updates/my-updates", {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch("/api/updates/my-updates", {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
useHead({
title: "My Updates - Ghost Guild",
});
</script>

1273
app/pages/member/profile.vue Normal file

File diff suppressed because it is too large Load diff

400
app/pages/members.vue Normal file
View file

@ -0,0 +1,400 @@
<template>
<div>
<PageHeader
title="Member Directory"
subtitle="Connect with members of the Ghost Guild community"
theme="purple"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Search and Filters -->
<div class="mb-8 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<UInput
v-model="searchQuery"
placeholder="Search by name or bio..."
icon="i-heroicons-magnifying-glass"
size="lg"
@input="debouncedSearch"
/>
</div>
<!-- Circle Filter -->
<USelect
v-model="selectedCircle"
:options="circleOptions"
placeholder="All Circles"
size="lg"
@change="loadMembers"
/>
</div>
<!-- Skills Filter -->
<div v-if="availableSkills.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-stone-400 mr-2 self-center"
>Filter by skill:</span
>
<button
v-for="skill in availableSkills.slice(
0,
showAllSkills ? undefined : 10,
)"
:key="skill"
type="button"
class="px-3 py-1 rounded-full text-sm transition-all border"
:class="
selectedSkills.includes(skill)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
: 'bg-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
"
@click="toggleSkill(skill)"
>
{{ skill }}
</button>
<button
v-if="availableSkills.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills"
>
{{
showAllSkills
? "Show less"
: `+${availableSkills.length - 10} more`
}}
</button>
</div>
</div>
<!-- Active Filters -->
<div
v-if="selectedCircle || selectedSkills.length > 0"
class="flex items-center gap-2 text-sm"
>
<span class="text-stone-400">Active filters:</span>
<span
v-if="selectedCircle"
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
>
{{ circleLabels[selectedCircle] }}
<button
type="button"
class="hover:text-purple-200"
@click="clearCircleFilter"
>
×
</button>
</span>
<button
v-if="selectedSkills.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters"
>
Clear all filters
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if="loading && !members.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading members...</p>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0">
<div class="mb-4 text-stone-400 text-sm">
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div>
<div class="space-y-2">
<div
v-for="member in members"
:key="member._id"
class="backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-4 hover:border-purple-500/50 transition-all group flex items-center gap-4"
>
<!-- Avatar -->
<div
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
>
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="w-8 h-8 object-contain"
/>
<span v-else class="text-xl text-stone-600">👻</span>
</div>
<!-- Name and Info -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<NuxtLink
:to="`/updates/user/${member._id}`"
class="font-semibold text-stone-100 hover:text-purple-300 transition-colors"
>
{{ member.name }}
</NuxtLink>
<span v-if="member.pronouns" class="text-sm text-stone-400">
{{ member.pronouns }}
</span>
<span
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30"
>
{{ circleLabels[member.circle] }}
</span>
<span v-if="member.studio" class="text-sm text-stone-400">
{{ member.studio }}
</span>
<span v-if="member.location" class="text-sm text-stone-500">
{{ member.location }}
</span>
</div>
</div>
<!-- Social Links -->
<div
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
class="flex gap-3 flex-shrink-0"
>
<a
v-if="member.socialLinks.mastodon"
:href="member.socialLinks.mastodon"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Mastodon"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z"
/>
</svg>
</a>
<a
v-if="member.socialLinks.linkedin"
:href="member.socialLinks.linkedin"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="LinkedIn"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</svg>
</a>
<a
v-if="member.socialLinks.website"
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Website"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
</a>
<a
v-if="member.socialLinks.other"
:href="member.socialLinks.other"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Other link"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No members found
</h3>
<p class="text-stone-400 mb-6">
Try adjusting your search or filters
</p>
<UButton variant="outline" @click="clearAllFilters">
Clear Filters
</UButton>
</div>
<!-- Not Authenticated Notice -->
<div
v-if="!isAuthenticated && members.length > 0"
class="mt-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6 text-center"
>
<p class="text-purple-200 mb-4">
🔒 Some member information is visible to members only
</p>
<div class="flex gap-3 justify-center">
<UButton to="/login" variant="outline"> Log In </UButton>
<UButton to="/join"> Join Ghost Guild </UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
// State
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const loading = ref(false);
const searchQuery = ref("");
const selectedCircle = ref("");
const selectedSkills = ref([]);
const showAllSkills = ref(false);
// Circle options
const circleOptions = [
{ label: "All Circles", value: "" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
];
const circleLabels = {
community: "Community",
founder: "Founder",
practitioner: "Practitioner",
};
// Helper to check if member has social links
const hasSocialLinks = (links) => {
if (!links) return false;
return !!(links.mastodon || links.linkedin || links.website || links.other);
};
// Load members
const loadMembers = async () => {
loading.value = true;
try {
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value) params.circle = selectedCircle.value;
if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(",");
const data = await $fetch("/api/members/directory", { params });
members.value = data.members;
totalCount.value = data.totalCount;
availableSkills.value = data.filters.availableSkills;
} catch (error) {
console.error("Failed to load members:", error);
} finally {
loading.value = false;
}
};
// Debounced search
let searchTimeout;
const debouncedSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadMembers();
}, 300);
};
// Toggle skill filter
const toggleSkill = (skill) => {
const index = selectedSkills.value.indexOf(skill);
if (index > -1) {
selectedSkills.value.splice(index, 1);
} else {
selectedSkills.value.push(skill);
}
loadMembers();
};
// Clear filters
const clearCircleFilter = () => {
selectedCircle.value = "";
loadMembers();
};
const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.value = "";
selectedSkills.value = [];
loadMembers();
};
// Load on mount
onMounted(() => {
loadMembers();
});
useHead({
title: "Member Directory - Ghost Guild",
meta: [
{
name: "description",
content:
"Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.",
},
],
});
</script>

View file

@ -1,68 +0,0 @@
<!-- pages/members/index.vue -->
<template>
<UDashboard>
<UDashboardPanel>
<UDashboardHeader>
<template #title> Welcome back, {{ member?.name }}! </template>
</UDashboardHeader>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UCard>
<template #header>Your Circle</template>
<p class="text-xl font-semibold">{{ circleLabel }}</p>
<p class="text-sm text-zinc-600 mt-1">{{ circleDescription }}</p>
<UButton variant="soft" size="sm" class="mt-2">
Request Circle Change
</UButton>
</UCard>
<UCard>
<template #header>Your Contribution</template>
<p class="text-xl font-semibold">{{ contributionLabel }}</p>
<p class="text-sm text-zinc-600">Supporting 2 solidarity spots</p>
<UButton variant="soft" size="sm" class="mt-2">
Adjust Contribution
</UButton>
</UCard>
</div>
<UCard class="mt-6">
<template #header>Quick Links</template>
<UList>
<li><NuxtLink to="/members/resources">Resource Library</NuxtLink></li>
<li><a href="https://gamma-space.slack.com">Slack Community</a></li>
<li><NuxtLink to="/members/events">Upcoming Events</NuxtLink></li>
</UList>
</UCard>
</UDashboardPanel>
</UDashboard>
</template>
<script setup>
import { computed } from 'vue'
import { getCircleByValue } from '~/config/circles'
import { getContributionTierByValue } from '~/config/contributions'
// Mock member data - in real app this would come from authentication/API
const member = ref({
name: 'Guest User',
circle: 'community',
contributionTier: '15'
})
// Computed properties for display labels
const circleLabel = computed(() => {
const circle = getCircleByValue(member.value?.circle)
return circle?.label || member.value?.circle
})
const circleDescription = computed(() => {
const circle = getCircleByValue(member.value?.circle)
return circle?.description || ''
})
const contributionLabel = computed(() => {
const tier = getContributionTierByValue(member.value?.contributionTier)
return tier?.label || `$${member.value?.contributionTier}/month`
})
</script>

View file

@ -0,0 +1,135 @@
<template>
<div>
<PageHeader
title="Edit Update"
subtitle="Make changes to your update"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div
v-if="loading"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading update...</p>
</div>
</div>
<!-- Edit Form -->
<div v-else-if="update" class="max-w-3xl">
<UpdateForm
:initial-data="update"
:submitting="submitting"
:error="error"
submit-label="Update"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<!-- Success Message -->
<div
v-if="success"
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300"> Update saved successfully!</p>
</div>
</div>
<!-- Not Found -->
<div v-else class="text-center py-20">
<p class="text-stone-400 mb-4">Update not found</p>
<UButton to="/updates" variant="outline" color="neutral">
Back to Updates
</UButton>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const route = useRoute();
const { isAuthenticated, checkMemberStatus, memberData } = useAuth();
const update = ref(null);
const loading = ref(true);
const submitting = ref(false);
const error = ref(null);
const success = ref(false);
// Load update
const loadUpdate = async () => {
loading.value = true;
try {
const data = await $fetch(`/api/updates/${route.params.id}`);
// Check if user is the author
if (memberData.value && data.author._id !== memberData.value.id) {
error.value = "You can only edit your own updates";
update.value = null;
return;
}
update.value = data;
} catch (err) {
console.error("Failed to load update:", err);
error.value = err.data?.statusMessage || "Failed to load update";
} finally {
loading.value = false;
}
};
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
return;
}
}
await loadUpdate();
});
const handleSubmit = async (formData) => {
submitting.value = true;
error.value = null;
success.value = false;
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: "PATCH",
body: formData,
});
success.value = true;
// Redirect to the update after a short delay
setTimeout(() => {
navigateTo(`/updates/${route.params.id}`);
}, 1000);
} catch (err) {
console.error("Failed to update:", err);
error.value =
err.data?.statusMessage || "Failed to save update. Please try again.";
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
navigateTo(`/updates/${route.params.id}`);
};
useHead({
title: "Edit Update - Ghost Guild",
});
</script>

View file

@ -0,0 +1,153 @@
<template>
<div>
<PageHeader
title="Update"
subtitle="Member update"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading update...</p>
</div>
</div>
<!-- Update Content -->
<div v-else-if="update" class="max-w-3xl">
<UpdateCard
:update="update"
:show-preview="false"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Comments Placeholder -->
<div
class="mt-8 p-8 border border-stone-700 rounded-lg bg-stone-800/30"
>
<h3 class="text-lg font-semibold text-stone-200 mb-4">Comments</h3>
<p class="text-stone-400 text-center py-8">Comments coming soon</p>
</div>
<!-- Back Button -->
<div class="mt-6">
<UButton
to="/updates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
>
Back to Updates
</UButton>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-20">
<p class="text-stone-400 mb-4">{{ error }}</p>
<UButton to="/updates" variant="outline" color="neutral">
Back to Updates
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const route = useRoute();
const update = ref(null);
const loading = ref(true);
const error = ref(null);
const showDeleteModal = ref(false);
const deleting = ref(false);
// Load update
const loadUpdate = async () => {
loading.value = true;
error.value = null;
try {
const data = await $fetch(`/api/updates/${route.params.id}`);
update.value = data;
console.log("✅ Update loaded successfully:", data);
} catch (err) {
console.error("❌ Failed to load update:", err);
console.error("Error details:", {
status: err.statusCode,
message: err.data?.statusMessage,
data: err.data,
});
error.value =
err.data?.statusMessage || err.statusMessage || "Update not found";
} finally {
loading.value = false;
}
};
onMounted(() => {
loadUpdate();
});
const handleEdit = () => {
navigateTo(`/updates/${route.params.id}/edit`);
};
const handleDelete = () => {
showDeleteModal.value = true;
};
const confirmDelete = async () => {
deleting.value = true;
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: "DELETE",
});
// Redirect to updates feed
await navigateTo("/updates");
} catch (err) {
console.error("Failed to delete update:", err);
alert("Failed to delete update. Please try again.");
deleting.value = false;
}
};
useHead({
title: computed(() =>
update.value
? `Update by ${update.value.author?.name} - Ghost Guild`
: "Update - Ghost Guild",
),
});
</script>

198
app/pages/updates/index.vue Normal file
View file

@ -0,0 +1,198 @@
<template>
<div>
<PageHeader
title="Community Updates"
subtitle="Share and discover what members are working on, learning, and thinking about"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- New Update Button -->
<div v-if="isAuthenticated" class="mb-8 flex justify-end">
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
</div>
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading updates...</p>
</div>
</div>
<!-- Updates Feed -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
Be the first to share an update with the community!
</p>
<UButton v-if="isAuthenticated" to="/updates/new">
Post Your First Update
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
const updates = ref([]);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Load initial updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
onMounted(() => {
loadUpdates();
});
useHead({
title: "Community Updates - Ghost Guild",
});
</script>

84
app/pages/updates/new.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<div>
<PageHeader
title="New Update"
subtitle="Share what you're working on, learning, or thinking about"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<div class="max-w-3xl">
<UpdateForm
:submitting="submitting"
:error="error"
submit-label="Post Update"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<!-- Success Message -->
<div
v-if="success"
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300"> Update posted successfully!</p>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { isAuthenticated, checkMemberStatus } = useAuth();
const submitting = ref(false);
const error = ref(null);
const success = ref(false);
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
}
}
});
const handleSubmit = async (formData) => {
submitting.value = true;
error.value = null;
success.value = false;
try {
const update = await $fetch("/api/updates", {
method: "POST",
body: formData,
});
success.value = true;
// Redirect to the update after a short delay
setTimeout(() => {
navigateTo(`/updates/${update._id}`);
}, 1000);
} catch (err) {
console.error("Failed to create update:", err);
error.value =
err.data?.statusMessage || "Failed to post update. Please try again.";
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
navigateTo("/updates");
};
useHead({
title: "New Update - Ghost Guild",
});
</script>

View file

@ -0,0 +1,193 @@
<template>
<div>
<PageHeader
:title="user?.name ? `${user.name}'s Updates` : 'User Updates'"
:subtitle="user?.name ? `All updates from ${user.name}` : 'Loading...'"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading updates...</p>
</div>
</div>
<!-- Updates Feed -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400">
{{ user?.name || "This user" }} hasn't posted any updates.
</p>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const route = useRoute();
const userId = computed(() => route.params.id);
const updates = ref([]);
const user = ref(null);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Load user updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch(`/api/updates/user/${userId.value}`, {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
user.value = response.user;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch(`/api/updates/user/${userId.value}`, {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
onMounted(() => {
loadUpdates();
});
useHead({
title: computed(() => user.value?.name ? `${user.value.name}'s Updates - Ghost Guild` : 'User Updates - Ghost Guild'),
});
</script>