Adding features
This commit is contained in:
parent
600fef2b7c
commit
2b55ca4104
75 changed files with 9796 additions and 2759 deletions
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue