Adding features
This commit is contained in:
parent
600fef2b7c
commit
2b55ca4104
75 changed files with 9796 additions and 2759 deletions
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("/background-dither.png")"
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
47
app/components/PrivacyToggle.vue
Normal file
47
app/components/PrivacyToggle.vue
Normal 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>
|
||||
189
app/components/UpdateCard.vue
Normal file
189
app/components/UpdateCard.vue
Normal 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>
|
||||
182
app/components/UpdateForm.vue
Normal file
182
app/components/UpdateForm.vue
Normal 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>
|
||||
|
|
@ -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("/background-dither.png");
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
211
app/pages/member/my-updates.vue
Normal file
211
app/pages/member/my-updates.vue
Normal 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
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
400
app/pages/members.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
135
app/pages/updates/[id]/edit.vue
Normal file
135
app/pages/updates/[id]/edit.vue
Normal 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>
|
||||
153
app/pages/updates/[id]/index.vue
Normal file
153
app/pages/updates/[id]/index.vue
Normal 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
198
app/pages/updates/index.vue
Normal 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
84
app/pages/updates/new.vue
Normal 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>
|
||||
193
app/pages/updates/user/[id].vue
Normal file
193
app/pages/updates/user/[id].vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue