Enhance application structure: Add runtime configuration for environment variables, integrate new dependencies for Cloudinary and UI components, and refactor member management features including improved forms and member dashboard. Update styles and layout for better user experience.

This commit is contained in:
Jennie Robinson Faber 2025-08-27 16:49:51 +01:00
parent 6e7e27ac4e
commit e4a0a9ab0f
61 changed files with 7902 additions and 950 deletions

View file

@ -0,0 +1,288 @@
<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 }}
</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>
</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>
</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>

View file

@ -0,0 +1,102 @@
<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>
<UButton
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"
>
<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>
<UButton
to="/login"
variant="outline"
size="sm"
class="mt-4 w-fit"
@click="mobileMenuOpen = false"
>
Login
</UButton>
</div>
</div>
</UContainer>
</nav>
</template>
<script setup>
import { ref } from 'vue'
const mobileMenuOpen = ref(false)
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 toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
}
// Close mobile menu when clicking outside
const closeMobileMenu = () => {
mobileMenuOpen.value = false
}
</script>

View file

@ -0,0 +1,201 @@
<template>
<div class="space-y-4">
<!-- Current Image Preview -->
<div v-if="modelValue?.url" class="relative">
<img
:src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover rounded-lg border border-gray-300"
@error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)"
/>
<button
@click="removeImage"
type="button"
class="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
<!-- Upload Area -->
<div
v-if="!modelValue?.url"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
:class="{ 'border-blue-400 bg-blue-50': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<div class="space-y-3">
<Icon name="heroicons:photo" class="w-12 h-12 text-gray-400 mx-auto" />
<div>
<p class="text-gray-600">
<button
type="button"
@click="$refs.fileInput.click()"
class="text-blue-600 hover:text-blue-500 font-medium"
>
Click to upload
</button>
or drag and drop
</p>
<p class="text-sm text-gray-500">PNG, JPG, GIF up to 10MB</p>
</div>
</div>
</div>
<!-- Alt Text Input -->
<div v-if="modelValue?.url">
<label class="block text-sm font-medium text-gray-700 mb-1">
Alt Text (for accessibility)
</label>
<input
:value="modelValue.alt || ''"
@input="updateAltText($event.target.value)"
placeholder="Describe this image..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Uploading...</span>
<span class="text-gray-600">{{ uploadProgress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`"
/>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="text-sm text-red-600">
{{ errorMessage }}
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Object,
default: () => null
}
})
const emit = defineEmits(['update:modelValue'])
const isDragging = ref(false)
const isUploading = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const fileInput = ref()
// Transform image URL for preview (smaller size)
const transformedImageUrl = computed(() => {
console.log('modelValue in computed:', props.modelValue)
// If we have the direct URL, use it
if (props.modelValue?.url) {
console.log('Using direct URL:', props.modelValue.url)
return props.modelValue.url
}
// Otherwise try to construct from publicId
if (props.modelValue?.publicId) {
const config = useRuntimeConfig()
const constructedUrl = `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_400,h_200,c_fill,f_auto,q_auto/${props.modelValue.publicId}`
console.log('Constructed URL:', constructedUrl)
return constructedUrl
}
console.log('No URL or publicId found')
return ''
})
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
uploadFile(file)
}
}
const handleDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer.files
if (files.length > 0) {
uploadFile(files[0])
}
}
const uploadFile = async (file) => {
// Validate file
if (!file.type.startsWith('image/')) {
errorMessage.value = 'Please select an image file'
return
}
if (file.size > 10 * 1024 * 1024) { // 10MB
errorMessage.value = 'File size must be less than 10MB'
return
}
errorMessage.value = ''
isUploading.value = true
uploadProgress.value = 0
try {
// Create form data for upload
const formData = new FormData()
formData.append('file', file)
// Upload to Cloudinary
const response = await $fetch(`/api/upload/image`, {
method: 'POST',
body: formData,
onUploadProgress: (progress) => {
uploadProgress.value = Math.round((progress.loaded / progress.total) * 100)
}
})
console.log('Upload response:', response)
// Update the model value
emit('update:modelValue', {
url: response.secure_url,
publicId: response.public_id,
alt: ''
})
} catch (error) {
console.error('Upload failed:', error)
errorMessage.value = 'Upload failed. Please try again.'
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const removeImage = () => {
emit('update:modelValue', null)
}
const updateAltText = (altText) => {
emit('update:modelValue', {
...props.modelValue,
alt: altText
})
}
</script>

View file

@ -0,0 +1,172 @@
<template>
<header
class="py-16 md:py-24"
:class="[
backgroundClass,
textColorClass
]"
>
<UContainer>
<div class="text-center max-w-4xl mx-auto">
<h1
class="font-bold mb-6 md:mb-8"
:class="[
titleSizeClass,
titleColorClass
]"
>
{{ title }}
</h1>
<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 class="flex items-center justify-between">
<button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
@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>
</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>
</slot>
</div>
<button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
@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>
</button>
</div>
</div>
<!-- Call to Action Button -->
<div v-if="showCta" class="flex justify-center">
<UButton
:to="ctaLink"
:size="ctaSize"
:color="ctaColor"
class="font-semibold"
>
{{ ctaText }}
</UButton>
</div>
<!-- Custom Content Slot -->
<div v-if="$slots.default">
<slot />
</div>
</div>
</UContainer>
</header>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
theme: {
type: String,
default: 'blue',
validator: (value) => ['blue', 'purple', 'emerald', 'gray'].includes(value)
},
size: {
type: String,
default: 'large',
validator: (value) => ['small', 'medium', 'large', 'hero'].includes(value)
},
showInteractiveArea: {
type: Boolean,
default: false
},
interactiveContent: {
type: String,
default: ''
},
showCta: {
type: Boolean,
default: false
},
ctaText: {
type: String,
default: 'Get Started'
},
ctaLink: {
type: String,
default: '/join'
},
ctaSize: {
type: String,
default: 'lg'
},
ctaColor: {
type: String,
default: 'primary'
}
})
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
})
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
})
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
})
const subtitleColorClass = computed(() => {
return 'text-gray-600 dark:text-gray-300'
})
const textColorClass = computed(() => {
return 'text-gray-900 dark:text-white'
})
</script>