Adding features

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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