Compare commits

...

3 commits

39 changed files with 2267 additions and 1960 deletions

View file

@ -6,8 +6,13 @@ export default defineAppConfig({
},
formField: {
slots: {
label: "block font-medium text-stone-200",
label: "block font-medium text-[--ui-text-dimmed]",
},
},
},
colorMode: {
preference: "system", // default value of $colorMode.preference
fallback: "dark", // fallback value if not system preference found
classSuffix: "", // default is '', set to '-mode' to have 'dark-mode' and 'light-mode'
},
});

View file

@ -2,14 +2,52 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
@theme {
/* 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 */
/* Ethereal color palette - light mode (inverted for light backgrounds) */
--color-ghost-50: #0a0a0a;
--color-ghost-100: #1a1a1a;
--color-ghost-200: #2a2a2a;
--color-ghost-300: #3a3a3a;
--color-ghost-400: #4a4a4a;
--color-ghost-500: #6a6a6a;
--color-ghost-600: #8a8a8a;
--color-ghost-700: #b0b0b0;
--color-ghost-800: #d0d0d0;
--color-ghost-900: #f0f0f0;
/* Subtle accent - barely visible blue-gray (light mode) */
--color-whisper-50: #0f1419;
--color-whisper-100: #1a1f2e;
--color-whisper-200: #252d40;
--color-whisper-300: #2f3b52;
--color-whisper-400: #3a4964;
--color-whisper-500: #4f5d7a;
--color-whisper-600: #687291;
--color-whisper-700: #8491a8;
--color-whisper-800: #a8b3c7;
--color-whisper-900: #d4dae6;
/* Sparkle accent (light mode) */
--color-sparkle-50: #202020;
--color-sparkle-100: #404040;
--color-sparkle-200: #606060;
--color-sparkle-300: #808080;
--color-sparkle-400: #a0a0a0;
--color-sparkle-500: #c0c0c0;
--color-sparkle-600: #d0d0d0;
--color-sparkle-700: #e8e8e8;
--color-sparkle-800: #f0f0f0;
--color-sparkle-900: #fafafa;
}
.dark {
/* Ethereal color palette - dark mode (original values) */
--color-ghost-50: #f0f0f0;
--color-ghost-100: #d0d0d0;
--color-ghost-200: #b0b0b0;
@ -21,7 +59,7 @@
--color-ghost-800: #1a1a1a;
--color-ghost-900: #0a0a0a;
/* Subtle accent - barely visible blue-gray */
/* Subtle accent - barely visible blue-gray (dark mode) */
--color-whisper-50: #d4dae6;
--color-whisper-100: #a8b3c7;
--color-whisper-200: #8491a8;
@ -33,7 +71,7 @@
--color-whisper-800: #1a1f2e;
--color-whisper-900: #0f1419;
/* Sparkle accent */
/* Sparkle accent (dark mode) */
--color-sparkle-50: #fafafa;
--color-sparkle-100: #f0f0f0;
--color-sparkle-200: #e8e8e8;
@ -46,33 +84,79 @@
--color-sparkle-900: #202020;
}
/* Global ethereal background */
/* Global ethereal background - light mode */
: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%);
--ethereal-bg:
radial-gradient(
circle at 20% 80%,
rgba(40, 40, 40, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(40, 40, 40, 0.02) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 40%,
rgba(40, 40, 40, 0.01) 0%,
transparent 50%
);
--halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
--halftone-pattern: radial-gradient(
circle,
rgba(0, 0, 0, 0.1) 1px,
transparent 1px
);
--halftone-size: 8px 8px;
}
/* Dark mode background */
.dark: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
);
}
html {
background: var(--color-ghost-900);
color: var(--color-ghost-200);
@apply text-[--ui-text];
}
body {
background: var(--ethereal-bg), var(--color-ghost-900);
background: var(--ethereal-bg), #f0f0f0;
background-attachment: fixed;
}
.dark body {
background: var(--ethereal-bg), #0a0a0a;
}
/* Halftone texture overlay */
.halftone-texture {
position: relative;
}
.halftone-texture::before {
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
@ -86,14 +170,28 @@ body {
/* Sparkle effects */
@keyframes sparkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
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; }
0%,
100% {
opacity: 0.2;
}
25% {
opacity: 0.8;
}
75% {
opacity: 0.4;
}
}
.sparkle-field {
@ -102,20 +200,50 @@ body {
}
.sparkle-field::after {
content: '';
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;
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;
@ -141,6 +269,22 @@ body {
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;
background-position:
0 0,
0 2px,
2px -2px,
-2px 0px;
}
/* Mobile responsive utilities */
@media (max-width: 1023px) {
/* Prevent horizontal scroll on mobile */
body {
overflow-x: hidden;
}
/* Adjust halftone pattern for mobile */
.halftone-texture::before {
background-size: 6px 6px;
}
}

View file

@ -7,7 +7,7 @@
>
<!-- Left: Copyright and minimal info -->
<div>
<p class="text-stone-500 text-xs mb-2">
<p class="text-ghost-500 text-xs mb-2">
© {{ currentYear }} Ghost Guild
</p>
</div>
@ -16,7 +16,7 @@
<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"
class="text-ghost-500 hover:text-ghost-300 transition-colors"
>
Contact
</a>

View file

@ -1,27 +1,38 @@
<template>
<nav
class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
:class="[
isMobile
? 'w-full flex flex-col bg-transparent'
: 'w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col bg-ghost-900 border-r border-ghost-700',
]"
>
<!-- Logo/Brand at top -->
<div class="p-8 border-b border-ghost-800 bg-blue-400">
<!-- Logo/Brand at top (desktop only) -->
<div v-if="!isMobile" class="p-8 border-b border-ghost-700 bg-primary-500">
<NuxtLink to="/" class="flex flex-col items-center gap-3 group">
<span
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
<span class="text-xl font-bold text-white ethereal-text tracking-wider"
>Ghost Guild Logo</span
>
</NuxtLink>
</div>
<!-- Vertical Navigation -->
<div class="flex-1 p-8 overflow-y-auto">
<ul class="space-y-6">
<div
:class="
isMobile ? 'flex-1 p-6 overflow-y-auto' : 'flex-1 p-8 overflow-y-auto'
"
>
<ul :class="isMobile ? 'space-y-4' : 'space-y-6'">
<li v-for="item in navigationItems" :key="item.path">
<NuxtLink :to="item.path" class="block group relative">
<NuxtLink
:to="item.path"
class="block group relative"
@click="handleNavigate"
>
<!-- 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"
class="text-ghost-200 hover:text-ghost-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
active-class="text-ghost-100 ethereal-text translate-x-2"
>
{{ item.label }}
</span>
@ -29,12 +40,24 @@
</li>
</ul>
<!-- Color Mode Switcher -->
<div class="mb-6">
<UColorModeButton size="md" class="w-full" />
</div>
<!-- Auth section -->
<div class="mt-12 pt-8 border-t border-ghost-800/50">
<div
:class="
isMobile
? 'mt-8 pt-6 border-t border-ghost-800/50'
: '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"
class="block text-ghost-300 hover:text-ghost-100 hover:ethereal-text transition-all duration-300 py-2"
@click="handleNavigate"
>
<span class="block text-sm text-whisper-400 mb-1">{{
memberData?.name || "Member"
@ -42,14 +65,14 @@
Dashboard
</NuxtLink>
<button
@click="logout"
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm"
@click="handleLogout"
class="text-ghost-500 hover:text-ghost-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">
<p class="text-ghost-400 text-sm mb-4">
Enter your email to receive a login link
</p>
@ -97,8 +120,30 @@
<script setup>
import { reactive, ref, computed } from "vue";
const props = defineProps({
isMobile: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["navigate"]);
const { isAuthenticated, logout, memberData } = useAuth();
const handleNavigate = () => {
if (props.isMobile) {
emit("navigate");
}
};
const handleLogout = async () => {
await logout();
if (props.isMobile) {
emit("navigate");
}
};
const publicNavigationItems = [
{ label: "Home", path: "/", accent: "entry point" },
{ label: "About", path: "/about", accent: "who we are" },
@ -112,7 +157,6 @@ const memberNavigationItems = [
{ 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" },
];

View file

@ -5,7 +5,7 @@
<img
:src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover rounded-lg border border-gray-300"
class="w-full h-48 object-cover rounded-lg border border-neutral-200"
@error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)"
/>
@ -62,7 +62,7 @@
: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"
class="w-full border border-neutral-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -91,111 +91,113 @@
const props = defineProps({
modelValue: {
type: Object,
default: () => null
}
})
default: () => null,
},
});
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(["update:modelValue"]);
const isDragging = ref(false)
const isUploading = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const fileInput = ref()
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)
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
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
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 ''
})
console.log("No URL or publicId found");
return "";
});
const handleFileSelect = (event) => {
const file = event.target.files[0]
const file = event.target.files[0];
if (file) {
uploadFile(file)
uploadFile(file);
}
}
};
const handleDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer.files
isDragging.value = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
uploadFile(files[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.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
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
errorMessage.value = "";
isUploading.value = true;
uploadProgress.value = 0;
try {
// Create form data for upload
const formData = new FormData()
formData.append('file', file)
const formData = new FormData();
formData.append("file", file);
// Upload to Cloudinary
const response = await $fetch(`/api/upload/image`, {
method: 'POST',
method: "POST",
body: formData,
onUploadProgress: (progress) => {
uploadProgress.value = Math.round((progress.loaded / progress.total) * 100)
}
})
uploadProgress.value = Math.round(
(progress.loaded / progress.total) * 100,
);
},
});
console.log('Upload response:', response)
console.log("Upload response:", response);
// Update the model value
emit('update:modelValue', {
emit("update:modelValue", {
url: response.secure_url,
publicId: response.public_id,
alt: ''
})
alt: "",
});
} catch (error) {
console.error('Upload failed:', error)
errorMessage.value = 'Upload failed. Please try again.'
console.error("Upload failed:", error);
errorMessage.value = "Upload failed. Please try again.";
} finally {
isUploading.value = false
uploadProgress.value = 0
isUploading.value = false;
uploadProgress.value = 0;
}
}
};
const removeImage = () => {
emit('update:modelValue', null)
}
emit("update:modelValue", null);
};
const updateAltText = (altText) => {
emit('update:modelValue', {
emit("update:modelValue", {
...props.modelValue,
alt: altText
})
}
alt: altText,
});
};
</script>

View file

@ -28,21 +28,21 @@
'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',
: 'bg-[--ui-bg-elevated] shadow-xl border border-blue-200',
]"
>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3 md:gap-4">
<button
:class="[
'p-3 rounded-full transition-all duration-300',
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
? 'bg-whisper-600/80 text-ghost-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"
class="w-5 h-5 md:w-6 md:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -56,14 +56,14 @@
</svg>
</button>
<div class="text-center flex-1">
<div class="text-center flex-1 min-w-0">
<slot name="interactive-content">
<p
:class="[
'text-lg',
'text-base md:text-lg',
props.theme === 'ethereal'
? 'text-stone-200'
: 'text-gray-600 dark:text-gray-300',
? 'text-ghost-200'
: 'text-[--ui-text-muted]',
]"
>
{{
@ -76,15 +76,15 @@
<button
:class="[
'p-3 rounded-full transition-all duration-300',
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
? 'bg-whisper-600/80 text-ghost-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"
class="w-5 h-5 md:w-6 md:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -178,12 +178,10 @@ 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",
blue: "bg-gradient-to-br from-blue-50 to-indigo-100",
purple: "bg-gradient-to-br from-purple-50 to-violet-100",
emerald: "bg-gradient-to-br from-emerald-50 to-teal-100",
gray: "bg-neutral-100",
ethereal:
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
};

View file

@ -1,25 +1,22 @@
<template>
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400">{{ label }}:</span>
<UButtonGroup size="xs">
<div class="flex items-center gap-3 text-sm">
<span class="text-ghost-300 font-medium">{{ label }}:</span>
<UButtonGroup size="sm" class="privacy-toggle-group">
<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'"
:variant="modelValue === 'members' ? 'solid' : 'outline'"
:color="modelValue === 'members' ? 'blue' : 'neutral'"
@click="updateValue('members')"
class="privacy-toggle-btn"
:class="{ 'is-selected': modelValue === 'members' }"
>
Members
</UButton>
<UButton
:variant="modelValue === 'private' ? 'solid' : 'ghost'"
:color="modelValue === 'private' ? 'blue' : 'gray'"
:variant="modelValue === 'private' ? 'solid' : 'outline'"
:color="modelValue === 'private' ? 'blue' : 'neutral'"
@click="updateValue('private')"
class="privacy-toggle-btn"
:class="{ 'is-selected': modelValue === 'private' }"
>
Private
</UButton>
@ -31,17 +28,43 @@
const props = defineProps({
modelValue: {
type: String,
default: 'members'
default: "members",
},
label: {
type: String,
default: 'Privacy'
}
})
default: "Privacy",
},
});
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(["update:modelValue"]);
const updateValue = (value) => {
emit('update:modelValue', value)
}
emit("update:modelValue", value);
};
</script>
<style scoped>
/* Unselected buttons - lighter background for visibility */
:deep(.privacy-toggle-btn:not(.is-selected)) {
background-color: rgb(68 64 60) !important; /* ghost-700 equivalent */
border-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
color: rgb(214 211 209) !important; /* ghost-300 equivalent */
}
:deep(.privacy-toggle-btn:not(.is-selected):hover) {
background-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
border-color: rgb(120 113 108) !important; /* ghost-500 equivalent */
}
/* Selected buttons - bright blue to stand out */
:deep(.privacy-toggle-btn.is-selected) {
background-color: rgb(59 130 246) !important; /* blue-500 */
border-color: rgb(59 130 246) !important; /* blue-500 */
color: white !important;
}
:deep(.privacy-toggle-btn.is-selected:hover) {
background-color: rgb(37 99 235) !important; /* blue-600 */
border-color: rgb(37 99 235) !important; /* blue-600 */
}
</style>

View file

@ -12,7 +12,7 @@
/>
<div
v-else
class="w-12 h-12 rounded-full bg-stone-700 flex items-center justify-center text-stone-300 font-bold"
class="w-12 h-12 rounded-full bg-ghost-700 flex items-center justify-center text-ghost-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
@ -23,30 +23,30 @@
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-stone-100">
<h3 class="font-semibold text-ghost-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-stone-300 transition-colors"
class="hover:text-ghost-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">
<div class="flex items-center gap-2 text-sm text-ghost-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-stone-500">(edited)</span>
<span v-if="isEdited" class="text-ghost-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
class="px-2 py-0.5 bg-ghost-700 text-ghost-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"
class="px-2 py-0.5 bg-ghost-700 text-ghost-300 rounded text-xs"
>
Public
</span>
@ -73,12 +73,12 @@
</div>
<!-- Content -->
<div class="text-stone-200 whitespace-pre-wrap break-words mb-3">
<div class="text-ghost-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"
class="text-ghost-400 hover:text-ghost-300 ml-1"
>
Read more
</NuxtLink>
@ -100,14 +100,14 @@
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-stone-400">
<div class="flex items-center gap-4 text-sm text-ghost-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-stone-300 transition-colors"
class="hover:text-ghost-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-stone-500">
<span v-if="update.commentsEnabled" class="text-ghost-500">
Comments (coming soon)
</span>
</div>

View file

@ -11,19 +11,19 @@
</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="border border-ghost-700 rounded-lg p-4 bg-ghost-800/30">
<h3 class="text-sm font-medium text-ghost-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"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Public</div>
<div class="text-sm text-stone-400">
<div class="text-ghost-200 font-medium">Public</div>
<div class="text-sm text-ghost-400">
Visible to everyone, including non-members
</div>
</div>
@ -34,11 +34,11 @@
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-stone-400"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Members Only</div>
<div class="text-sm text-stone-400">
<div class="text-ghost-200 font-medium">Members Only</div>
<div class="text-sm text-ghost-400">
Only visible to Ghost Guild members
</div>
</div>
@ -49,11 +49,11 @@
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-stone-400"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Private</div>
<div class="text-sm text-stone-400">Only visible to you</div>
<div class="text-ghost-200 font-medium">Private</div>
<div class="text-sm text-ghost-400">Only visible to you</div>
</div>
</label>
</div>
@ -66,15 +66,17 @@
<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">
<div class="text-ghost-200 font-medium">Enable Comments</div>
<div class="text-sm text-ghost-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">
<div
class="flex justify-between items-center pt-4 border-t border-ghost-700"
>
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>

View file

@ -1,46 +1,54 @@
export const useAuth = () => {
const memberData = useState('auth.member', () => null)
const memberData = useState("auth.member", () => null);
const isAuthenticated = computed(() => !!memberData.value)
const isAuthenticated = computed(() => !!memberData.value);
const isMember = computed(() => !!memberData.value)
const isMember = computed(() => !!memberData.value);
const checkMemberStatus = async () => {
console.log('🔍 checkMemberStatus called')
console.log(' - Current memberData:', !!memberData.value)
console.log("🔍 checkMemberStatus called");
console.log(" - Current memberData:", !!memberData.value);
try {
console.log(' - Making API call to /api/auth/member...')
const response = await $fetch('/api/auth/member')
console.log(' - API response received:', { email: response.email, id: response.id })
memberData.value = response
console.log(' - ✅ Member authenticated successfully')
return true
console.log(" - Making API call to /api/auth/member...");
const response = await $fetch("/api/auth/member");
console.log(" - API response received:", {
email: response.email,
id: response.id,
});
memberData.value = response;
console.log(" - ✅ Member authenticated successfully");
return true;
} catch (error) {
console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage)
memberData.value = null
console.log(' - Cleared memberData')
return false
}
console.error(
" - ❌ Failed to fetch member status:",
error.statusCode,
error.statusMessage,
);
memberData.value = null;
console.log(" - Cleared memberData");
return false;
}
};
const logout = async () => {
try {
await $fetch('/api/auth/logout', {
method: 'POST'
})
memberData.value = null
await navigateTo('/')
await $fetch("/api/auth/logout", {
method: "POST",
});
memberData.value = null;
await navigateTo("/");
} catch (error) {
console.error('Logout failed:', error)
}
console.error("Logout failed:", error);
}
};
return {
isAuthenticated: readonly(isAuthenticated),
isMember: readonly(isMember),
memberData: readonly(memberData),
checkMemberStatus,
logout
}
}
fetchMember: checkMemberStatus, // Alias for consistency
logout,
};
};

View file

@ -0,0 +1,16 @@
export const usePeerSupport = () => {
const updateSettings = async (settings) => {
return await $fetch('/api/members/me/peer-support', {
method: 'PATCH',
body: settings
});
};
const getSupporters = async (topic) => {
return await $fetch('/api/peer-support', {
query: topic ? { topic } : {}
});
};
return { updateSettings, getSupporters };
};

View file

@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-stone-800 flex relative">
<div class="min-h-screen bg-ghost-900 flex relative">
<!-- Background image at top - full page width -->
<div
class="absolute inset-x-0 pointer-events-none z-0"
@ -21,15 +21,48 @@
"
/>
<!-- Mobile Header -->
<div
class="lg:hidden fixed top-0 left-0 right-0 z-50 bg-ghost-900/95 backdrop-blur-md border-b border-ghost-700"
>
<div class="flex items-center justify-between p-4">
<NuxtLink
to="/"
class="text-lg font-bold text-white ethereal-text tracking-wider"
>
Ghost Guild
</NuxtLink>
<UButton
icon="i-lucide-menu"
color="neutral"
variant="ghost"
size="lg"
@click="isMobileMenuOpen = true"
aria-label="Open menu"
/>
</div>
</div>
<!-- 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">
<div class="p-4 pt-20 md:p-8 md:pt-8 lg:p-16 max-w-4xl relative">
<slot />
</div>
<AppFooter />
</div>
<!-- Navigation Column - Right -->
<AppNavigation class="relative z-20" />
<!-- Desktop Navigation Column - Right -->
<AppNavigation class="hidden lg:block relative z-20" />
<!-- Mobile Navigation Drawer -->
<USlideover v-model:open="isMobileMenuOpen" side="right">
<template #body>
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
</template>
</USlideover>
</div>
</template>
<script setup>
const isMobileMenuOpen = ref(false);
</script>

View file

@ -9,22 +9,22 @@
/>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
<h2 class="text-3xl font-bold text-[--ui-text] mb-6">
How Ghost Guild Works
</h2>
<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">
<p class="text-xl font-semibold text-[--ui-text] 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>
<ul
class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-3 mb-12"
class="text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
>
<li>
<strong>Equal access:</strong> The entire knowledge commons, all
@ -49,13 +49,13 @@
</section>
<!-- Find Your Circle -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Find Your Circle
</h2>
<p class="text-lg text-gray-700 dark:text-gray-300 mb-12">
<p class="text-lg text-[--ui-text-muted] 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.
@ -63,21 +63,19 @@
<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">
<div class="bg-[--ui-bg] rounded-xl p-8">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Community Circle
</h3>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
<p class="text-lg text-[--ui-text-muted] mb-6">
You're exploring what cooperatives could mean for your work
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<ul class="text-[--ui-text-muted] space-y-2">
<li>
Curious about alternatives to traditional studio structures
</li>
@ -88,12 +86,10 @@
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<ul class="text-[--ui-text-muted] space-y-2">
<li>Understanding cooperative basics</li>
<li>Connecting with others asking similar questions</li>
<li>Exploring real examples from game studios</li>
@ -102,12 +98,10 @@
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<ul class="text-[--ui-text-muted] space-y-2">
<li>Individual game workers</li>
<li>Researchers and students</li>
<li>Industry allies and supporters</li>
@ -117,11 +111,11 @@
</div>
<!-- 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">
<div class="bg-[--ui-bg] rounded-xl p-8">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Founder Circle
</h3>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
<p class="text-lg text-[--ui-text-muted] mb-6">
You're actively building or transitioning to a cooperative model
</p>
@ -188,11 +182,11 @@
</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">
<div class="bg-[--ui-bg] rounded-xl p-8">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Practitioner Circle
</h3>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
<p class="text-lg text-[--ui-text-muted] mb-6">
You're operating a cooperative and contributing to the field
</p>
@ -246,14 +240,14 @@
</section>
<!-- Important Notes -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
<h2 class="text-3xl font-bold text-[--ui-text] mb-8">
Important Notes
</h2>
<div class="space-y-6 text-lg text-gray-700 dark:text-gray-300">
<div class="space-y-6 text-lg text-[--ui-text-muted]">
<p>
<strong>Movement between circles is fluid.</strong> As you move
along in your journey, you can shift circles anytime. Just let us

View file

@ -4,13 +4,13 @@
<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"
class="text-6xl md:text-8xl font-bold text-ghost-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">
<p class="text-ghost-100 text-lg max-w-md">
We'd be happy to answer any questions<br />
you might have about Ghost Guild
</p>
@ -21,7 +21,7 @@
<!-- Contact Form -->
<section class="mb-32 relative">
<div class="mb-12">
<h2 class="text-3xl font-light text-stone-200 mb-4">
<h2 class="text-3xl font-light text-ghost-200 mb-4">
Send us a message (or email hello@ghostguild.org)
</h2>
</div>
@ -79,7 +79,7 @@
: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"
class="px-12 border border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Send Message
</UButton>
@ -98,7 +98,7 @@
</div>
<div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900">
<p class="text-stone-300 text-center">
<p class="text-ghost-300 text-center">
{{ error }}
</p>
</div>

View file

@ -1,27 +1,27 @@
<template>
<div
v-if="pending"
class="min-h-screen bg-stone-900 flex items-center justify-center"
class="min-h-screen bg-ghost-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-stone-200">Loading event details...</p>
<p class="text-ghost-200">Loading event details...</p>
</div>
</div>
<div
v-else-if="error"
class="min-h-screen bg-stone-900 flex items-center justify-center"
class="min-h-screen bg-ghost-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-stone-100 mb-2">Event Not Found</h2>
<p class="text-stone-300 mb-6">
<h2 class="text-2xl font-bold text-ghost-100 mb-2">Event Not Found</h2>
<p class="text-ghost-300 mb-6">
The event you're looking for doesn't exist.
</p>
<NuxtLink to="/events" class="text-blue-400 hover:underline">
@ -64,11 +64,11 @@
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
<!-- Event Details Section -->
<section class="py-16 bg-stone-900">
<section class="py-16 bg-ghost-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Event Meta Info -->
<div class="bg-stone-800 rounded-xl p-6 mb-8 border border-stone-700">
<div class="bg-ghost-800 rounded-xl p-6 mb-8 border border-ghost-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3">
<Icon
@ -76,8 +76,8 @@
class="w-6 h-6 text-blue-400"
/>
<div>
<p class="text-sm text-stone-400">Date</p>
<p class="font-semibold text-stone-100">
<p class="text-sm text-ghost-400">Date</p>
<p class="font-semibold text-ghost-100">
{{ formatDate(event.startDate) }}
</p>
</div>
@ -86,8 +86,8 @@
<div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-stone-400">Time</p>
<p class="font-semibold text-stone-100">
<p class="text-sm text-ghost-400">Time</p>
<p class="font-semibold text-ghost-100">
{{ formatTime(event.startDate, event.endDate) }}
</p>
</div>
@ -96,8 +96,8 @@
<div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-stone-400">Location</p>
<p class="font-semibold text-stone-100">
<p class="text-sm text-ghost-400">Location</p>
<p class="font-semibold text-ghost-100">
{{ event.location }}
</p>
</div>
@ -153,7 +153,7 @@
>
<div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
<span class="text-sm font-medium text-stone-200"
<span class="text-sm font-medium text-ghost-200"
>Recommended for:</span
>
<div class="flex flex-wrap gap-2">
@ -170,15 +170,15 @@
<!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-stone-100 mb-4">
<h2 class="text-2xl font-bold text-ghost-100 mb-4">
About This Event
</h2>
<p class="text-stone-200">
<p class="text-ghost-200">
{{ event.description }}
</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-stone-100 mb-4">
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
Event Agenda
</h3>
<ul class="space-y-3">
@ -192,7 +192,7 @@
>
{{ index + 1 }}
</span>
<span class="text-stone-200">{{ item }}</span>
<span class="text-ghost-200">{{ item }}</span>
</li>
</ul>
</div>
@ -201,7 +201,7 @@
v-if="event.speakers && event.speakers.length > 0"
class="mt-8"
>
<h3 class="text-xl font-semibold text-stone-100 mb-4">
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
Speakers
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -211,21 +211,21 @@
class="flex items-start space-x-4"
>
<div
class="w-16 h-16 bg-stone-700 rounded-full flex items-center justify-center"
class="w-16 h-16 bg-ghost-700 rounded-full flex items-center justify-center"
>
<Icon
name="heroicons:user"
class="w-8 h-8 text-stone-500"
class="w-8 h-8 text-ghost-500"
/>
</div>
<div>
<p class="font-semibold text-stone-100">
<p class="font-semibold text-ghost-100">
{{ speaker.name }}
</p>
<p class="text-sm text-stone-300">
<p class="text-sm text-ghost-300">
{{ speaker.role }}
</p>
<p class="text-sm text-stone-400 mt-1">
<p class="text-sm text-ghost-400 mt-1">
{{ speaker.bio }}
</p>
</div>
@ -237,9 +237,9 @@
<!-- Registration Section -->
<div
v-if="!event.isCancelled"
class="bg-stone-800 rounded-xl p-8 border border-stone-700"
class="bg-ghost-800 rounded-xl p-8 border border-ghost-700"
>
<h3 class="text-xl font-bold text-stone-100 mb-6">
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
@ -322,7 +322,7 @@
<div>
<label
for="name"
class="block text-sm font-medium text-stone-200 mb-2"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
@ -338,7 +338,7 @@
<div>
<label
for="email"
class="block text-sm font-medium text-stone-200 mb-2"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
@ -354,7 +354,7 @@
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-stone-200 mb-2"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Membership Status
</label>
@ -388,16 +388,16 @@
<!-- Event Capacity -->
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-stone-700"
class="mt-6 pt-6 border-t border-ghost-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-stone-300">Event Capacity</span>
<span class="text-sm text-ghost-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-stone-100">
<span class="text-sm font-semibold text-ghost-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div
class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
@ -410,9 +410,9 @@
</div>
<!-- Additional Information -->
<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">
<div class="mt-8 p-6 bg-ghost-800 rounded-xl border border-ghost-700">
<h4 class="font-semibold text-ghost-100 mb-3">Questions?</h4>
<p class="text-sm text-ghost-200 mb-3">
If you have any questions about this event please drop us a line.
</p>
<a

View file

@ -8,7 +8,7 @@
/>
<!-- Events Section with Tabs -->
<section class="py-20 bg-stone-900 dark:bg-stone-950">
<section class="py-20 bg-ghost-900 dark:bg-ghost-950">
<UContainer>
<UTabs
v-model="activeTab"
@ -27,10 +27,10 @@
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">
<div class="text-2xl font-bold text-ghost-100">
{{ event.start.getDate() }}
</div>
<div class="text-xs text-stone-400 uppercase">
<div class="text-xs text-ghost-400 uppercase">
{{
event.start.toLocaleDateString("en-US", {
month: "short",
@ -42,7 +42,7 @@
<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"
class="text-lg font-semibold text-ghost-100 group-hover:text-blue-400 transition-colors"
>
{{ event.title }}
</h3>
@ -53,7 +53,7 @@
/>
</div>
<p class="text-sm text-stone-300 mb-2 line-clamp-2">
<p class="text-sm text-ghost-300 mb-2 line-clamp-2">
{{ event.content }}
</p>
@ -72,7 +72,7 @@
<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"
class="w-5 h-5 text-ghost-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
@ -83,10 +83,10 @@
<ClientOnly>
<div
v-if="pending"
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<p class="text-stone-200">Loading events...</p>
<p class="text-ghost-200">Loading events...</p>
</div>
</div>
<VueCal
@ -110,13 +110,13 @@
/>
<template #fallback>
<div
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
class="min-h-[400px] bg-ghost-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>
<p class="text-ghost-200">Loading calendar...</p>
</div>
</div>
</template>
@ -130,14 +130,14 @@
<!-- Event Series -->
<section
v-if="activeSeries.length > 0"
class="py-20 bg-stone-800 dark:bg-stone-900"
class="py-20 bg-ghost-800 dark:bg-ghost-900"
>
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Active Event Series
</h2>
<p class="text-stone-300 max-w-2xl mx-auto">
<p class="text-ghost-300 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections.
</p>
@ -149,7 +149,7 @@
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
class="bg-ghost-900 rounded-xl p-6 shadow-lg border border-ghost-700"
>
<div class="flex items-start justify-between mb-4">
<div
@ -160,17 +160,17 @@
>
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-stone-400">
<div class="flex items-center gap-1 text-xs text-ghost-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-stone-100 mb-2">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
<p class="text-sm text-ghost-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
@ -186,22 +186,22 @@
>
{{ event.series?.position || "?" }}
</div>
<span class="text-stone-300 truncate">{{ event.title }}</span>
<span class="text-ghost-300 truncate">{{ event.title }}</span>
</div>
<span class="text-stone-400">
<span class="text-ghost-400">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div
v-if="series.events.length > 3"
class="text-xs text-stone-400 text-center pt-1"
class="text-xs text-ghost-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-stone-400">
<div class="text-ghost-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span
@ -223,20 +223,20 @@
</section>
<!-- Attend Our Events -->
<section class="py-20 bg-stone-800 dark:bg-stone-900">
<section class="py-20 bg-ghost-800 dark:bg-ghost-900">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Attend Our Events
</h2>
</div>
<div class="max-w-4xl mx-auto">
<div
class="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
class="bg-ghost-900 rounded-2xl p-8 border border-ghost-700 mb-12"
>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-stone-200 mb-6">
<p class="text-lg leading-relaxed text-ghost-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
@ -244,7 +244,7 @@
dolorum alias nulla!
</p>
<p class="text-lg leading-relaxed text-stone-200 mb-6">
<p class="text-lg leading-relaxed text-ghost-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
@ -265,31 +265,31 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center">
<h3 class="text-lg font-semibold text-stone-100 mb-2">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Monthly Meetups
</h3>
<p class="text-sm text-stone-300">
<p class="text-sm text-ghost-300">
Casual knowledge sharing sessions
</p>
</div>
<div class="text-center">
<h3 class="text-lg font-semibold text-stone-100 mb-2">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Workshops
</h3>
<p class="text-sm text-stone-300">
<p class="text-sm text-ghost-300">
Hands-on learning about cooperative and worker-centric business
models
</p>
</div>
<div class="text-center">
<h3 class="text-lg font-semibold text-stone-100 mb-2">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Social Events
</h3>
<p class="text-sm text-stone-300">
<p class="text-sm text-ghost-300">
Game nights, socials, and more
</p>
</div>

View file

@ -5,14 +5,14 @@
<div class="relative">
<!-- Large artistic title -->
<h1
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
class="text-6xl md:text-8xl font-bold text-ghost-100 ethereal-text leading-tight mb-8"
>
Become a Ghostie
</h1>
<!-- Floating subtitle -->
<div class="mb-16">
<p class="text-stone-100 text-lg max-w-md">
<p class="text-ghost-100 text-lg max-w-md">
A community for creatives and game devs<br />
exploring cooperative models
</p>
@ -33,7 +33,7 @@
<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"
class="inline-block px-8 py-3 border border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Join Us Today
</NuxtLink>
@ -55,13 +55,13 @@
>
<!-- 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">
<h3 class="text-xl text-ghost-100 mb-3">{{ circle.label }}</h3>
<p class="text-ghost-200 text-sm leading-relaxed mb-4">
{{ circle.description }}
</p>
<!-- Features as inline text -->
<div class="text-sm text-stone-400">
<div class="text-sm text-ghost-400">
<span v-for="(feature, i) in circle.features" :key="feature">
{{ feature
}}<span v-if="i < circle.features.length - 1"> </span>
@ -77,7 +77,7 @@
<!-- 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>
<h2 class="text-3xl font-light text-ghost-200 mb-12">Why Join?</h2>
</div>
<div class="ml-12 relative">
@ -86,11 +86,11 @@
/>
<div class="max-w-2xl">
<p class="text-stone-300 leading-loose text-lg mb-8">
<p class="text-ghost-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">
<p class="text-ghost-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.
@ -98,7 +98,7 @@
</div>
<div
class="absolute -bottom-8 right-0 text-6xl text-stone-800 opacity-20 font-bold"
class="absolute -bottom-8 right-0 text-6xl text-ghost-800 opacity-20 font-bold"
>
?
</div>

View file

@ -9,13 +9,13 @@
/>
<!-- Membership Sign Up Form -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Membership Sign Up
</h2>
<p class="text-lg text-gray-700 dark:text-gray-300">
<p class="text-lg text-[--ui-text]">
Choose your circle to connect with others at your stage. Choose your
contribution based on what you can afford. Everyone gets full
access.
@ -30,8 +30,8 @@
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 1
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
1
@ -39,21 +39,16 @@
<span
class="ml-2 font-medium"
:class="
currentStep === 1
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
currentStep === 1 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Information
</span>
</div>
<div v-if="needsPayment" class="w-16 h-1 bg-neutral-200">
<div
v-if="needsPayment"
class="w-16 h-1 bg-gray-200 dark:bg-gray-700"
>
<div
class="h-full bg-gray-900 dark:bg-white transition-all"
class="h-full bg-neutral-900 transition-all"
:style="{ width: currentStep >= 2 ? '100%' : '0%' }"
/>
</div>
@ -63,8 +58,8 @@
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 2
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
2
@ -72,18 +67,16 @@
<span
class="ml-2 font-medium"
:class="
currentStep === 2
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
currentStep === 2 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Payment
</span>
</div>
<div class="w-16 h-1 bg-gray-200 dark:bg-gray-700">
<div class="w-16 h-1 bg-neutral-200">
<div
class="h-full bg-gray-900 dark:bg-white transition-all"
class="h-full bg-neutral-900 transition-all"
:style="{ width: currentStep >= 3 ? '100%' : '0%' }"
/>
</div>
@ -93,8 +86,8 @@
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 3
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
<span v-if="needsPayment">3</span>
@ -103,9 +96,7 @@
<span
class="ml-2 font-medium"
:class="
currentStep === 3
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
currentStep === 3 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Confirmation
@ -120,7 +111,7 @@
</div>
<!-- Step 1: Information -->
<div v-if="currentStep === 1" class="bg-white dark:bg-gray-800">
<div v-if="currentStep === 1" class="bg-[--ui-bg-elevated]">
<UForm :state="form" class="space-y-8" @submit="handleSubmit">
<!-- Personal Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -154,8 +145,8 @@
class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md"
:class="
form.circle === option.value
? 'border-gray-900 dark:border-white bg-gray-50 dark:bg-gray-800'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500'
? 'border-neutral-900 bg-[--ui-bg]'
: 'border-neutral-200 hover:border-neutral-400'
"
>
<input
@ -166,12 +157,12 @@
class="mb-3"
/>
<div class="font-medium text-lg mb-2">{{ option.label }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
<div class="text-sm text-[--ui-text-muted]">
{{ option.description }}
</div>
</label>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-3 italic">
<p class="text-sm text-[--ui-text-muted] mt-3 italic">
Not sure? Start with Community - you can always move.
</p>
</div>
@ -213,23 +204,23 @@
<!-- Step 2: Payment -->
<div
v-if="currentStep === 2"
class="bg-white dark:bg-gray-800 rounded-xl p-8"
class="bg-[--ui-bg-elevated] rounded-xl p-8"
>
<div class="mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Payment Information
</h3>
<p class="text-gray-600 dark:text-gray-400">
<p class="text-[--ui-text-muted]">
You're signing up for the {{ selectedTier.label }} plan
</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
<p class="text-lg font-semibold text-[--ui-text] mt-2">
${{ selectedTier.amount }} CAD / month
</p>
</div>
<!-- Payment Instructions -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6">
<p class="text-gray-700 dark:text-gray-300">
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6">
<p class="text-[--ui-text]">
Click "Complete Payment" below to open the secure payment modal
and verify your payment method.
</p>
@ -254,11 +245,11 @@
<!-- Step 3: Confirmation -->
<div
v-if="currentStep === 3"
class="bg-white dark:bg-gray-800 rounded-xl p-8"
class="bg-[--ui-bg-elevated] rounded-xl p-8"
>
<div class="text-center">
<div
class="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6"
class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"
>
<svg
class="w-10 h-10 text-green-500"
@ -275,7 +266,7 @@
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<h3 class="text-2xl font-bold text-[--ui-text] mb-4">
Welcome to Ghost Guild!
</h3>
@ -283,43 +274,39 @@
<UAlert color="green" :title="successMessage" />
</div>
<div
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left"
>
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6 text-left">
<h4 class="font-semibold mb-3">Membership Details:</h4>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Name:</dt>
<dt class="text-[--ui-text-muted]">Name:</dt>
<dd class="font-medium">{{ form.name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Email:</dt>
<dt class="text-[--ui-text-muted]">Email:</dt>
<dd class="font-medium">{{ form.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Circle:</dt>
<dt class="text-[--ui-text-muted]">Circle:</dt>
<dd class="font-medium capitalize">{{ form.circle }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">
Contribution:
</dt>
<dt class="text-[--ui-text-muted]">Contribution:</dt>
<dd class="font-medium">{{ selectedTier.label }}</dd>
</div>
<div v-if="customerCode" class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Member ID:</dt>
<dt class="text-[--ui-text-muted]">Member ID:</dt>
<dd class="font-medium">{{ customerCode }}</dd>
</div>
</dl>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">
<p class="text-[--ui-text-muted] mb-4">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-8">
<p class="text-gray-700 dark:text-gray-300 text-center">
<div class="bg-[--ui-bg] rounded-lg p-4 mb-8">
<p class="text-[--ui-text] text-center">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
@ -339,14 +326,14 @@
</section>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
How Ghost Guild Works
</h2>
<p class="text-lg text-gray-700 dark:text-gray-300">
<p class="text-lg text-[--ui-text]">
Every member gets everything. Your circle helps you find relevant
content and peers. Your contribution helps sustain our solidarity
economy.
@ -355,13 +342,11 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Full Access -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-6">
<h3
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Full Access
</h3>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<ul class="text-[--ui-text] space-y-2">
<li>Complete resource library</li>
<li>All workshops and events</li>
<li>Slack community</li>
@ -371,13 +356,11 @@
</div>
<!-- Circle-Specific Guidance -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-6">
<h3
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Circle-Specific Guidance
</h3>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<ul class="text-[--ui-text] space-y-2">
<li>Curated resources for your stage</li>
<li>Connection with peers on similar journeys</li>
<li>Relevant workshop recommendations</li>
@ -390,25 +373,23 @@
</section>
<!-- How to Join -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="space-y-8">
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
1
</div>
</div>
<div class="flex-1">
<h3
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Pick your circle
</h3>
<p class="text-gray-700 dark:text-gray-300">
<p class="text-[--ui-text]">
Where are you in your co-op journey? Select based on where you
are in your cooperative journey - exploring, building, or
practicing. Not sure? Start with Community.
@ -419,18 +400,16 @@
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
2
</div>
</div>
<div class="flex-1">
<h3
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Choose your contribution
</h3>
<p class="text-gray-700 dark:text-gray-300">
<p class="text-[--ui-text]">
What can you afford? ($0-50+/month) Choose based on your
financial capacity. From $0 for those who need support to $50+
for those who can sponsor others. You can adjust anytime.
@ -441,18 +420,16 @@
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
3
</div>
</div>
<div class="flex-1">
<h3
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Join the community
</h3>
<p class="text-gray-700 dark:text-gray-300">
<p class="text-[--ui-text]">
Get instant access to everything. Fill out your profile, agree
to our community guidelines, and complete payment (if
applicable). You'll get instant access to our community.

View file

@ -9,18 +9,20 @@
/>
<!-- Login Form -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-md">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
<h2 class="text-3xl font-bold text-primary-500 mb-4">
Passwordless Login
</h2>
<p class="text-gray-600 dark:text-gray-300">
<p class="text-[--ui-text-muted]">
Enter your email to receive a secure login link
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
>
<UForm :state="loginForm" class="space-y-6" @submit="handleLogin">
<!-- Email Field -->
<UFormField label="Email Address" name="email" required>
@ -34,7 +36,7 @@
</UFormField>
<!-- Passwordless Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
<div class="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
@ -46,8 +48,9 @@
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
</div>
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3">
We'll send you a secure login link via email. No password needed!
<p class="text-primary-700 text-sm mt-3">
We'll send you a secure login link via email. No password
needed!
</p>
</div>
@ -66,23 +69,31 @@
</UForm>
<!-- Success/Error Messages -->
<div v-if="loginSuccess" 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">
Magic link sent! Check your email and click the link to sign in.
<div
v-if="loginSuccess"
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
>
<p class="text-green-700 text-center">
Magic link sent! Check your email and click the link to sign
in.
</p>
</div>
<div v-if="loginError" 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">
{{ loginError }}
</p>
<div
v-if="loginError"
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
>
<p class="text-red-700 text-center"> {{ loginError }}</p>
</div>
<!-- Sign Up Link -->
<div class="mt-6 text-center">
<p class="text-gray-600 dark:text-gray-400">
<p class="text-[--ui-text-muted]">
Don't have an account?
<NuxtLink to="/join" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
<NuxtLink
to="/join"
class="text-primary-500 hover:underline font-medium"
>
Join Ghost Guild
</NuxtLink>
</p>
@ -92,19 +103,25 @@
</section>
<!-- Forgot Password -->
<section id="forgot-password" class="py-20 bg-gray-50 dark:bg-gray-800">
<section id="forgot-password" class="py-20 bg-neutral-50">
<UContainer class="max-w-md">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
<h2 class="text-3xl font-bold text-primary-500 mb-4">
Forgot Password
</h2>
<p class="text-gray-600 dark:text-gray-300">
<p class="text-[--ui-text-muted]">
Enter your email to receive a password reset link
</p>
</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="forgotPasswordForm" class="space-y-6" @submit="handleForgotPassword">
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
>
<UForm
:state="forgotPasswordForm"
class="space-y-6"
@submit="handleForgotPassword"
>
<!-- Email Field -->
<UFormField label="Email Address" name="email" required>
<UInput
@ -117,7 +134,7 @@
</UFormField>
<!-- Reset Instructions -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
<div class="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
@ -129,8 +146,9 @@
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
</div>
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3">
We'll send you a secure link to reset your password. Check your email inbox and spam folder.
<p class="text-primary-700 text-sm mt-3">
We'll send you a secure link to reset your password. Check your
email inbox and spam folder.
</p>
</div>
@ -150,36 +168,39 @@
</UForm>
<!-- Success/Error Messages -->
<div v-if="resetSuccess" 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">
<div
v-if="resetSuccess"
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
>
<p class="text-green-700 text-center">
Password reset link sent! Check your email.
</p>
</div>
<div v-if="resetError" 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">
{{ resetError }}
</p>
<div
v-if="resetError"
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
>
<p class="text-red-700 text-center"> {{ resetError }}</p>
</div>
</div>
</UContainer>
</section>
<!-- Sign In CTA -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Sign In
</h2>
<h2 class="text-3xl font-bold text-primary-500 mb-8">Sign In</h2>
<div class="space-y-4 mb-8">
<div class="h-2 bg-blue-500 rounded-full w-64 mx-auto" />
<div class="h-2 bg-blue-300 rounded-full w-48 mx-auto" />
</div>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to access your account and connect with the community?
<p class="text-lg text-[--ui-text-muted] mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to
access your account and connect with the community?
</p>
<UButton
@ -195,10 +216,10 @@
</section>
<!-- Access Your Dashboard -->
<section class="py-20 bg-blue-50 dark:bg-blue-900/20">
<section class="py-20 bg-primary-50">
<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">
<h2 class="text-3xl font-bold text-primary-500 mb-8">
Access Your Dashboard
</h2>
@ -209,53 +230,59 @@
<div class="h-2 bg-blue-200 rounded-full w-full max-w-xs mx-auto" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-lg border border-blue-200 dark:border-blue-800 mb-8">
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once you're logged in, you'll have access to:
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-lg border border-primary-200 mb-8"
>
<p class="text-lg text-[--ui-text-muted] mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once
you're logged in, you'll have access to:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Community forums and discussions</span>
<span class="text-[--ui-text]"
>Community forums and discussions</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-400 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Member directory and networking</span>
<span class="text-[--ui-text]"
>Member directory and networking</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-300 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Educational resources and workshops</span>
<span class="text-[--ui-text]"
>Educational resources and workshops</span
>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-500 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Cooperative development tools</span>
<span class="text-[--ui-text]"
>Cooperative development tools</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Mentorship opportunities</span>
<span class="text-[--ui-text]">Mentorship opportunities</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-300 rounded-full" />
<span class="text-gray-700 dark:text-gray-300">Project collaboration spaces</span>
<span class="text-[--ui-text]"
>Project collaboration spaces</span
>
</div>
</div>
</div>
</div>
<div class="text-center">
<p class="text-gray-600 dark:text-gray-300 mb-4">
New to Ghost Guild?
</p>
<UButton
to="/join"
variant="outline"
size="lg"
class="px-8"
>
<p class="text-[--ui-text-muted] mb-4">New to Ghost Guild?</p>
<UButton to="/join" variant="outline" size="lg" class="px-8">
Create Your Account
</UButton>
</div>
@ -266,112 +293,112 @@
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { reactive, ref, computed } from "vue";
// Login form state
const loginForm = reactive({
email: ''
})
email: "",
});
// Forgot password form state
const forgotPasswordForm = reactive({
email: ''
})
email: "",
});
// UI state
const isLoggingIn = ref(false)
const isResettingPassword = ref(false)
const loginSuccess = ref(false)
const loginError = ref('')
const resetSuccess = ref(false)
const resetError = ref('')
const isLoggingIn = ref(false);
const isResettingPassword = ref(false);
const loginSuccess = ref(false);
const loginError = ref("");
const resetSuccess = ref(false);
const resetError = ref("");
// Form validation
const isLoginFormValid = computed(() => {
return loginForm.email && loginForm.email.includes('@')
})
return loginForm.email && loginForm.email.includes("@");
});
// Login handler
const handleLogin = async () => {
if (isLoggingIn.value) return
if (isLoggingIn.value) return;
isLoggingIn.value = true
loginError.value = ''
loginSuccess.value = false
isLoggingIn.value = true;
loginError.value = "";
loginSuccess.value = false;
try {
// Call the passwordless login API
const response = await $fetch('/api/auth/login', {
method: 'POST',
const response = await $fetch("/api/auth/login", {
method: "POST",
body: {
email: loginForm.email
}
})
email: loginForm.email,
},
});
if (response.success) {
loginSuccess.value = true
loginError.value = ''
loginSuccess.value = true;
loginError.value = "";
// Clear the form
loginForm.email = ''
loginForm.email = "";
}
} catch (err) {
console.error('Login error:', err)
console.error("Login error:", err);
// Handle different error types
if (err.statusCode === 404) {
loginError.value = 'No account found with that email address. Please check your email or create an account.'
loginError.value =
"No account found with that email address. Please check your email or create an account.";
} else if (err.statusCode === 500) {
loginError.value = 'Failed to send login email. Please try again later.'
loginError.value = "Failed to send login email. Please try again later.";
} else {
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
loginError.value =
err.statusMessage || "Something went wrong. Please try again.";
}
} finally {
isLoggingIn.value = false
isLoggingIn.value = false;
}
}
};
// Forgot password handler
const handleForgotPassword = async () => {
if (isResettingPassword.value) return
if (isResettingPassword.value) return;
isResettingPassword.value = true
resetError.value = ''
resetSuccess.value = false
isResettingPassword.value = true;
resetError.value = "";
resetSuccess.value = false;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500))
await new Promise((resolve) => setTimeout(resolve, 1500));
resetSuccess.value = true
resetSuccess.value = true;
// Reset form after success
setTimeout(() => {
forgotPasswordForm.email = ''
resetSuccess.value = false
}, 5000)
forgotPasswordForm.email = "";
resetSuccess.value = false;
}, 5000);
} catch (err) {
console.error('Password reset error:', err)
resetError.value = 'Failed to send reset email. Please try again.'
console.error("Password reset error:", err);
resetError.value = "Failed to send reset email. Please try again.";
} finally {
isResettingPassword.value = false
isResettingPassword.value = false;
}
}
};
// Scroll functions
const scrollToLoginForm = () => {
const formSection = document.querySelector('form')
const formSection = document.querySelector("form");
if (formSection) {
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
formSection.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
};
const scrollToForgotPassword = () => {
const forgotSection = document.getElementById('forgot-password')
const forgotSection = document.getElementById("forgot-password");
if (forgotSection) {
forgotSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
forgotSection.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
};
</script>

View file

@ -18,7 +18,7 @@
<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>
<p class="text-ghost-300">Loading your dashboard...</p>
</div>
</div>
@ -36,10 +36,10 @@
<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">
<h1 class="text-2xl font-bold text-ghost-100 ethereal-text">
Welcome to Ghost Guild, {{ memberData?.name }}!
</h1>
<p class="text-stone-300 mt-2">
<p class="text-ghost-300 mt-2">
Your membership is active and you're part of our cooperative
community.
</p>
@ -53,7 +53,7 @@
</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"
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-ghost-200 font-bold text-xl"
>
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
</div>
@ -63,13 +63,13 @@
<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="text-ghost-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="text-ghost-400">Contribution:</span>
<span class="font-medium text-whisper-300 ml-1"
>${{ memberData?.contributionTier }} CAD/month</span
>
@ -86,28 +86,16 @@
}"
>
<template #header>
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
<h2 class="text-xl font-bold text-ghost-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"
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
@ -120,7 +108,7 @@
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
@ -133,7 +121,7 @@
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
@ -146,7 +134,7 @@
<UButton
to="/member/profile"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
<template #leading>
@ -179,10 +167,10 @@
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
<h3 class="text-lg font-semibold mb-2 text-ghost-100">
Upcoming Events
</h3>
<p class="text-stone-300 mb-4">
<p class="text-ghost-300 mb-4">
Discover and register for community events and workshops.
</p>
@ -191,7 +179,7 @@
to="/events"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
>
View Events
</UButton>
@ -217,8 +205,8 @@
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">Community</h3>
<p class="text-stone-300 mb-4">
<h3 class="text-lg font-semibold mb-2 text-ghost-100">Community</h3>
<p class="text-ghost-300 mb-4">
Connect with other members in your circle and beyond.
</p>
@ -227,7 +215,7 @@
to="/members"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Members
</UButton>
@ -253,10 +241,10 @@
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
<h3 class="text-lg font-semibold mb-2 text-ghost-100">
Account Settings
</h3>
<p class="text-stone-300 mb-4">
<p class="text-ghost-300 mb-4">
Manage your profile and membership settings.
</p>
@ -265,7 +253,7 @@
to="/member/profile#account"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Manage Account
</UButton>
@ -283,14 +271,14 @@
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
Your Upcoming Events
</h2>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-stone-300 hover:text-stone-100"
class="text-ghost-300 hover:text-ghost-100"
>
Browse All Events
</UButton>
@ -334,10 +322,10 @@
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-stone-100 mb-1">
<h3 class="font-semibold text-ghost-100 mb-1">
{{ evt.title }}
</h3>
<div class="flex items-center gap-4 text-sm text-stone-400">
<div class="flex items-center gap-4 text-sm text-ghost-400">
<span class="flex items-center gap-1">
<Icon name="heroicons:calendar" class="w-4 h-4" />
{{ formatEventDate(evt.startDate) }}
@ -351,7 +339,7 @@
<div class="flex-shrink-0">
<Icon
name="heroicons:chevron-right"
class="w-5 h-5 text-stone-500"
class="w-5 h-5 text-ghost-500"
/>
</div>
</div>
@ -361,108 +349,20 @@
<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"
class="w-12 h-12 text-ghost-600 mx-auto mb-3"
/>
<p class="text-stone-400 mb-4">
<p class="text-ghost-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"
class="border-ghost-600 text-ghost-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 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>
@ -471,8 +371,6 @@
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const recentUpdates = ref([]);
const loadingUpdates = ref(false);
const registeredEvents = ref([]);
const loadingEvents = ref(false);
@ -506,21 +404,6 @@ const { pending: authPending } = await useLazyAsyncData(
},
);
// 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;
}
};
// Load registered events
const loadRegisteredEvents = async () => {
console.log(
@ -575,23 +458,6 @@ const capitalize = (str) => {
.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 "";
@ -626,7 +492,6 @@ const formatEventTime = (dateString) => {
};
onMounted(() => {
loadRecentUpdates();
loadRegisteredEvents();
});

View file

@ -11,8 +11,8 @@
<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>
<div class="text-ghost-300">
<span class="text-2xl font-bold text-ghost-100">{{ total }}</span>
{{ total === 1 ? "update" : "updates" }} posted
</div>
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
@ -25,9 +25,9 @@
>
<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"
class="w-8 h-8 border-4 border-ghost-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your updates...</p>
<p class="text-ghost-400">Loading your updates...</p>
</div>
</div>
@ -62,7 +62,7 @@
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
class="text-ghost-600"
>
<path
stroke-linecap="round"
@ -72,10 +72,10 @@
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
<h3 class="text-lg font-medium text-ghost-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
<p class="text-ghost-400 mb-6">
Share your first update with the community
</p>
<UButton to="/updates/new" icon="i-lucide-plus">

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
<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">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<UInput
@ -26,21 +26,28 @@
<!-- Circle Filter -->
<USelect
v-model="selectedCircle"
:options="circleOptions"
placeholder="All Circles"
:items="circleOptions"
size="lg"
@change="loadMembers"
@update:model-value="loadMembers"
/>
<!-- Peer Support Filter -->
<USelect
v-model="peerSupportFilter"
:items="peerSupportOptions"
size="lg"
@update:model-value="loadMembers"
/>
</div>
<!-- Skills Filter -->
<div v-if="availableSkills.length > 0">
<div v-if="availableSkills && availableSkills.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-stone-400 mr-2 self-center"
<span class="text-sm text-ghost-400 mr-2 self-center"
>Filter by skill:</span
>
<button
v-for="skill in availableSkills.slice(
v-for="skill in (availableSkills || []).slice(
0,
showAllSkills ? undefined : 10,
)"
@ -50,14 +57,14 @@
: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'
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
"
@click="toggleSkill(skill)"
>
{{ skill }}
</button>
<button
v-if="availableSkills.length > 10"
v-if="availableSkills && availableSkills.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills"
@ -71,12 +78,55 @@
</div>
</div>
<!-- Peer Support Topics Filter -->
<div v-if="availableTopics && availableTopics.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-ghost-400 mr-2 self-center"
>Filter by peer support topic:</span
>
<button
v-for="topic in (availableTopics || []).slice(
0,
showAllTopics ? undefined : 10,
)"
:key="topic"
type="button"
class="px-3 py-1 rounded-full text-sm transition-all border"
:class="
selectedTopics.includes(topic)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
"
@click="toggleTopic(topic)"
>
{{ topic }}
</button>
<button
v-if="availableTopics && availableTopics.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllTopics = !showAllTopics"
>
{{
showAllTopics
? "Show less"
: `+${availableTopics.length - 10} more`
}}
</button>
</div>
</div>
<!-- Active Filters -->
<div
v-if="selectedCircle || selectedSkills.length > 0"
class="flex items-center gap-2 text-sm"
v-if="
selectedCircle ||
peerSupportFilter ||
selectedSkills.length > 0 ||
selectedTopics.length > 0
"
class="flex items-center gap-2 text-sm flex-wrap"
>
<span class="text-stone-400">Active filters:</span>
<span class="text-ghost-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"
@ -90,8 +140,21 @@
×
</button>
</span>
<span
v-if="peerSupportFilter"
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
>
Offering Peer Support
<button
v-if="selectedSkills.length > 0"
type="button"
class="hover:text-purple-200"
@click="clearPeerSupportFilter"
>
×
</button>
</span>
<button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters"
@ -110,75 +173,88 @@
<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>
<p class="text-ghost-400">Loading members...</p>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0">
<div class="mb-4 text-stone-400 text-sm">
<div class="mb-4 text-ghost-400 text-sm">
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div>
<div class="space-y-2">
<div class="space-y-4">
<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"
class="backdrop-blur-sm bg-ghost-900/50 border border-ghost-700/50 rounded-lg p-6 hover:border-purple-500/50 transition-all group"
>
<!-- Header Section -->
<div class="flex items-start gap-4 mb-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"
class="w-16 h-16 rounded-lg bg-ghost-800 border border-ghost-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"
class="w-12 h-12 object-contain"
/>
<span v-else class="text-xl text-stone-600">👻</span>
<span v-else class="text-2xl text-ghost-600">👻</span>
</div>
<!-- Name and Info -->
<!-- Name and Meta Info -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<div class="flex items-baseline gap-2 flex-wrap mb-2">
<NuxtLink
:to="`/updates/user/${member._id}`"
class="font-semibold text-stone-100 hover:text-purple-300 transition-colors"
class="font-semibold text-lg text-ghost-100 hover:text-purple-300 transition-colors"
>
{{ member.name }}
</NuxtLink>
<span v-if="member.pronouns" class="text-sm text-stone-400">
<span v-if="member.pronouns" class="text-sm text-ghost-400">
{{ member.pronouns }}
</span>
</div>
<div class="flex items-center gap-2 flex-wrap mb-2">
<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">
<span v-if="member.studio" class="text-sm text-ghost-400">
{{ member.studio }}
</span>
<span v-if="member.location" class="text-sm text-stone-500">
{{ member.location }}
<span v-if="member.location" class="text-sm text-ghost-500">
📍 {{ member.location }}
</span>
<span v-if="member.timeZone" class="text-sm text-ghost-500">
🕐 {{ member.timeZone }}
</span>
</div>
</div>
<!-- Social Links -->
<div
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
class="flex gap-3 flex-shrink-0"
v-if="
member.socialLinks && hasSocialLinks(member.socialLinks)
"
class="flex gap-3"
>
<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"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="Mastodon"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<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"
/>
@ -189,10 +265,14 @@
:href="member.socialLinks.linkedin"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="LinkedIn"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<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"
/>
@ -203,7 +283,7 @@
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="Website"
>
<svg
@ -225,7 +305,7 @@
:href="member.socialLinks.other"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="Other link"
>
<svg
@ -245,6 +325,133 @@
</div>
</div>
</div>
<!-- Bio -->
<div v-if="member.bio" class="mb-4">
<p class="text-ghost-300 text-sm leading-relaxed">
{{ member.bio }}
</p>
</div>
<!-- Peer Support Section -->
<div
v-if="member.peerSupport?.enabled"
class="mb-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg"
>
<div class="flex items-center gap-2 mb-2">
<span class="text-purple-300 font-medium text-sm">
💜 Offering Peer Support
</span>
</div>
<!-- Topics -->
<div
v-if="
member.peerSupport.topics &&
member.peerSupport.topics.length > 0
"
class="mb-2"
>
<div class="flex flex-wrap gap-1">
<span
v-for="topic in member.peerSupport.topics"
:key="topic"
class="px-2 py-0.5 bg-purple-500/20 text-purple-200 rounded text-xs border border-purple-500/40"
>
{{ topic }}
</span>
</div>
</div>
<!-- Personal Message -->
<div
v-if="member.peerSupport.personalMessage"
class="text-sm text-ghost-300 italic mb-2"
>
"{{ member.peerSupport.personalMessage }}"
</div>
<!-- Availability -->
<div
v-if="member.peerSupport.availability"
class="text-xs text-ghost-400 mb-2"
>
Availability: {{ member.peerSupport.availability }}
</div>
<!-- Contact Button -->
<a
v-if="member.peerSupport.slackUsername"
:href="`slack://user?team=T03A96LV4&id=${member.slackUserId}`"
@click.prevent="openSlackDM(member)"
class="inline-block px-3 py-1.5 bg-purple-500/20 text-purple-300 rounded border border-purple-500/30 hover:bg-purple-500/30 transition-colors text-sm font-medium cursor-pointer"
>
Message {{ member.peerSupport.slackUsername }} on Slack
</a>
</div>
<!-- Offering and Looking For -->
<div
v-if="member.offering || member.lookingFor"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<!-- Offering -->
<div v-if="member.offering" class="space-y-2">
<h4 class="text-xs font-semibold text-purple-400 uppercase">
Offering
</h4>
<p
v-if="member.offering.description"
class="text-ghost-300 text-sm"
>
{{ member.offering.description }}
</p>
<div
v-if="
member.offering.tags && member.offering.tags.length > 0
"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in member.offering.tags"
:key="tag"
class="px-2 py-0.5 bg-green-500/20 text-green-300 rounded text-xs border border-green-500/30"
>
{{ tag }}
</span>
</div>
</div>
<!-- Looking For -->
<div v-if="member.lookingFor" class="space-y-2">
<h4 class="text-xs font-semibold text-purple-400 uppercase">
Looking For
</h4>
<p
v-if="member.lookingFor.description"
class="text-ghost-300 text-sm"
>
{{ member.lookingFor.description }}
</p>
<div
v-if="
member.lookingFor.tags &&
member.lookingFor.tags.length > 0
"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in member.lookingFor.tags"
:key="tag"
class="px-2 py-0.5 bg-blue-500/20 text-blue-300 rounded text-xs border border-blue-500/30"
>
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
@ -254,7 +461,7 @@
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
class="text-ghost-600"
>
<path
stroke-linecap="round"
@ -264,10 +471,10 @@
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
<h3 class="text-lg font-medium text-ghost-300 mb-2">
No members found
</h3>
<p class="text-stone-400 mb-6">
<p class="text-ghost-400 mb-6">
Try adjusting your search or filters
</p>
<UButton variant="outline" @click="clearAllFilters">
@ -300,15 +507,19 @@ const { isAuthenticated } = useAuth();
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const loading = ref(false);
const availableTopics = ref([]);
const loading = ref(true); // Start with loading true
const searchQuery = ref("");
const selectedCircle = ref("");
const selectedCircle = ref("all");
const peerSupportFilter = ref("all");
const selectedSkills = ref([]);
const selectedTopics = ref([]);
const showAllSkills = ref(false);
const showAllTopics = ref(false);
// Circle options
const circleOptions = [
{ label: "All Circles", value: "" },
{ label: "All Circles", value: "all" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
@ -320,6 +531,12 @@ const circleLabels = {
practitioner: "Practitioner",
};
// Peer support filter options
const peerSupportOptions = [
{ label: "All Members", value: "all" },
{ label: "Offering Peer Support", value: "true" },
];
// Helper to check if member has social links
const hasSocialLinks = (links) => {
if (!links) return false;
@ -333,17 +550,27 @@ const loadMembers = async () => {
try {
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value) params.circle = selectedCircle.value;
if (selectedCircle.value && selectedCircle.value !== "all")
params.circle = selectedCircle.value;
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
params.peerSupport = peerSupportFilter.value;
if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(",");
if (selectedTopics.value.length > 0)
params.topics = selectedTopics.value.join(",");
const data = await $fetch("/api/members/directory", { params });
members.value = data.members;
totalCount.value = data.totalCount;
availableSkills.value = data.filters.availableSkills;
members.value = data.members || [];
totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters?.availableSkills || [];
availableTopics.value = data.filters?.availableTopics || [];
} catch (error) {
console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
availableTopics.value = [];
} finally {
loading.value = false;
}
@ -369,19 +596,53 @@ const toggleSkill = (skill) => {
loadMembers();
};
// Toggle topic filter
const toggleTopic = (topic) => {
const index = selectedTopics.value.indexOf(topic);
if (index > -1) {
selectedTopics.value.splice(index, 1);
} else {
selectedTopics.value.push(topic);
}
loadMembers();
};
// Clear filters
const clearCircleFilter = () => {
selectedCircle.value = "";
loadMembers();
selectedCircle.value = "all";
};
const clearPeerSupportFilter = () => {
peerSupportFilter.value = "all";
};
const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.value = "";
selectedCircle.value = "all";
peerSupportFilter.value = "all";
selectedSkills.value = [];
selectedTopics.value = [];
loadMembers();
};
// Slack DM functionality
const openSlackDM = async (member) => {
const username = member.peerSupport?.slackUsername || member.name;
// Copy username to clipboard
try {
await navigator.clipboard.writeText(username);
} catch (err) {
console.log("Could not copy to clipboard:", err);
}
// Show alert and open Slack
alert(
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
);
window.open("https://gammaspace.slack.com", "_blank");
};
// Load on mount
onMounted(() => {
loadMembers();

View file

@ -0,0 +1,12 @@
<template>
<div></div>
</template>
<script setup>
// Redirect to members directory with peer support filter
definePageMeta({
middleware: defineNuxtRouteMiddleware(() => {
return navigateTo("/members?peerSupport=true");
}),
});
</script>

View file

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

View file

@ -1,153 +0,0 @@
<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>

View file

@ -1,198 +0,0 @@
<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>

View file

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

View file

@ -1,193 +0,0 @@
<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>

View file

@ -0,0 +1,163 @@
/**
* Migration Script: Profile Fields Restructure
*
* This script migrates member data from the old schema to the new schema:
* - Removes `skills` field
* - Converts `offering` from String to { text: String, tags: [String] }
* - Converts `lookingFor` from String to { text: String, tags: [String] }
* - Converts `peerSupport.topics` to `peerSupport.skillTopics` and `peerSupport.supportTopics`
* - Removes `privacy.skills`
*/
import mongoose from 'mongoose';
import Member from '../server/models/member.js';
import { connectDB } from '../server/utils/mongoose.js';
// Curated list of conversational support topics
const CONVERSATIONAL_TOPICS = [
'Co-founder relationships',
'Burnout prevention',
'Impostor syndrome',
'Work-life boundaries',
'Conflict resolution',
'General chat & support',
];
async function migrateProfileFields() {
try {
await connectDB();
console.log('Connected to database');
// Find all members
const members = await Member.find({});
console.log(`Found ${members.length} members to migrate`);
let migratedCount = 0;
let skippedCount = 0;
for (const member of members) {
let needsUpdate = false;
const updates = {};
// Migrate skills -> offering.tags (if offering doesn't have tags yet)
if (member.skills && member.skills.length > 0) {
console.log(`\nMember ${member.name} (${member.email}):`);
console.log(` - Has skills: ${member.skills.join(', ')}`);
// If offering is still a string, convert it and add skills as tags
if (typeof member.offering === 'string') {
updates['offering'] = {
text: member.offering || '',
tags: member.skills, // Move skills to offering tags
};
console.log(` - Migrating skills to offering.tags`);
needsUpdate = true;
}
// Remove skills field
updates.$unset = { skills: 1 };
needsUpdate = true;
}
// Migrate offering from string to object (if not already done)
if (typeof member.offering === 'string' && !updates['offering']) {
updates['offering'] = {
text: member.offering || '',
tags: [],
};
console.log(` - Converting offering to object structure`);
needsUpdate = true;
}
// Migrate lookingFor from string to object
if (typeof member.lookingFor === 'string') {
updates['lookingFor'] = {
text: member.lookingFor || '',
tags: [],
};
console.log(` - Converting lookingFor to object structure`);
needsUpdate = true;
}
// Migrate peer support topics
if (member.peerSupport?.topics && member.peerSupport.topics.length > 0) {
const skillTopics = [];
const supportTopics = [];
// Split topics into skill-based and conversational
for (const topic of member.peerSupport.topics) {
if (CONVERSATIONAL_TOPICS.includes(topic)) {
supportTopics.push(topic);
} else {
skillTopics.push(topic);
}
}
updates['peerSupport.skillTopics'] = skillTopics;
updates['peerSupport.supportTopics'] = supportTopics;
updates['$unset'] = {
...(updates['$unset'] || {}),
'peerSupport.topics': 1
};
console.log(` - Splitting peer support topics:`);
console.log(` Skill topics: ${skillTopics.join(', ') || 'none'}`);
console.log(` Support topics: ${supportTopics.join(', ') || 'none'}`);
needsUpdate = true;
}
// Remove privacy.skills if it exists
if (member.privacy?.skills) {
updates['$unset'] = {
...(updates['$unset'] || {}),
'privacy.skills': 1
};
needsUpdate = true;
}
// Apply updates
if (needsUpdate) {
const updateOps = { ...updates };
const unsetOps = updateOps.$unset;
delete updateOps.$unset;
const finalUpdate = {};
if (Object.keys(updateOps).length > 0) {
finalUpdate.$set = updateOps;
}
if (unsetOps && Object.keys(unsetOps).length > 0) {
finalUpdate.$unset = unsetOps;
}
await Member.updateOne({ _id: member._id }, finalUpdate);
console.log(` ✓ Updated`);
migratedCount++;
} else {
skippedCount++;
}
}
console.log('\n=== Migration Complete ===');
console.log(`Total members: ${members.length}`);
console.log(`Migrated: ${migratedCount}`);
console.log(`Skipped (already migrated): ${skippedCount}`);
} catch (error) {
console.error('Migration error:', error);
throw error;
} finally {
await mongoose.connection.close();
console.log('\nDatabase connection closed');
}
}
// Run migration
migrateProfileFields()
.then(() => {
console.log('\n✓ Migration script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n✗ Migration script failed:', error);
process.exit(1);
});

View file

@ -41,13 +41,14 @@ export default defineEventHandler(async (event) => {
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
skills: member.skills,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
};
} catch (err) {
console.error("Token verification error:", err);

View file

@ -24,7 +24,9 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event);
const search = query.search || "";
const circle = query.circle || "";
const skills = query.skills ? query.skills.split(",") : [];
const tags = query.tags ? query.tags.split(",") : [];
const peerSupport = query.peerSupport || "";
const topics = query.topics ? query.topics.split(",") : [];
// Build query
const dbQuery = {
@ -37,6 +39,11 @@ export default defineEventHandler(async (event) => {
dbQuery.circle = circle;
}
// Filter by peer support availability
if (peerSupport === "true") {
dbQuery["peerSupport.enabled"] = true;
}
// Search by name or bio
if (search) {
dbQuery.$or = [
@ -45,15 +52,41 @@ export default defineEventHandler(async (event) => {
];
}
// Filter by skills
if (skills.length > 0) {
dbQuery.skills = { $in: skills };
// Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) {
dbQuery.$or = [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
];
// If search is also present, combine with AND
if (search) {
dbQuery.$and = [
{
$or: [
{ name: { $regex: search, $options: "i" } },
{ bio: { $regex: search, $options: "i" } },
],
},
{
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
},
];
delete dbQuery.$or;
}
}
// Filter by peer support topics
if (topics.length > 0) {
dbQuery["peerSupport.topics"] = { $in: topics };
}
try {
const members = await Member.find(dbQuery)
.select(
"name pronouns timeZone avatar studio bio skills location socialLinks offering lookingFor privacy circle createdAt"
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport slackUserId createdAt",
)
.sort({ createdAt: -1 })
.lean();
@ -83,26 +116,42 @@ export default defineEventHandler(async (event) => {
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
if (isVisible("studio")) filtered.studio = member.studio;
if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("skills")) filtered.skills = member.skills;
if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// Always show peer support if enabled (it's opt-in, so public by nature)
if (member.peerSupport?.enabled) {
filtered.peerSupport = member.peerSupport;
filtered.slackUserId = member.slackUserId;
}
return filtered;
});
// Get unique skills for filter options
const allSkills = members
.flatMap((m) => m.skills || [])
.filter((skill, index, self) => self.indexOf(skill) === index)
// Get unique tags for filter options (from both offering and lookingFor)
const allTags = members
.flatMap((m) => [
...(m.offering?.tags || []),
...(m.lookingFor?.tags || []),
])
.filter((tag, index, self) => self.indexOf(tag) === index)
.sort();
// Get unique peer support topics
const allTopics = members
.filter((m) => m.peerSupport?.enabled)
.flatMap((m) => m.peerSupport?.topics || [])
.filter((topic, index, self) => self.indexOf(topic) === index)
.sort();
return {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allSkills,
availableSkills: allTags,
availableTopics: allTopics,
},
};
} catch (error) {

View file

@ -0,0 +1,120 @@
import jwt from "jsonwebtoken";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const body = await readBody(event);
// Build update object for peer support settings
const updateData = {
"peerSupport.enabled": body.enabled || false,
"peerSupport.skillTopics": body.skillTopics || [],
"peerSupport.supportTopics": body.supportTopics || [],
"peerSupport.availability": body.availability || "",
"peerSupport.personalMessage": body.personalMessage || "",
"peerSupport.slackUsername": body.slackUsername || "",
};
// If Slack username provided and peer support enabled, try to fetch Slack user ID
if (body.enabled && body.slackUsername) {
try {
console.log(
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
);
// Dynamically import the Slack service
const { getSlackService } = await import("../../../utils/slack.ts");
const slackService = getSlackService();
if (slackService) {
console.log(
"[Peer Support] Slack service initialized, looking up user...",
);
const slackUserId = await slackService.findUserIdByUsername(
body.slackUsername,
);
if (slackUserId) {
updateData["slackUserId"] = slackUserId;
console.log(
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
);
// Now get/create the DM channel
console.log("[Peer Support] Opening DM channel...");
const dmChannelId = await slackService.openDMChannel(slackUserId);
if (dmChannelId) {
updateData["peerSupport.slackDMChannelId"] = dmChannelId;
console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`);
} else {
console.warn("[Peer Support] Could not get DM channel ID");
}
} else {
console.warn(
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
);
}
} else {
console.log(
"[Peer Support] Slack service not configured, skipping user ID lookup",
);
}
} catch (error) {
console.error(
"[Peer Support] Error fetching Slack user ID:",
error.message,
);
console.error("[Peer Support] Stack trace:", error.stack);
// Continue anyway - we'll still save the username
}
}
try {
const member = await Member.findByIdAndUpdate(
memberId,
{ $set: updateData },
{ new: true, runValidators: true },
);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
return {
success: true,
peerSupport: member.peerSupport,
};
} catch (error) {
console.error("Peer support update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to update peer support settings",
});
}
});

View file

@ -34,11 +34,8 @@ export default defineEventHandler(async (event) => {
"avatar",
"studio",
"bio",
"skills",
"location",
"socialLinks",
"offering",
"lookingFor",
"showInDirectory",
"helcimCustomerId",
];
@ -50,7 +47,6 @@ export default defineEventHandler(async (event) => {
"avatarPrivacy",
"studioPrivacy",
"bioPrivacy",
"skillsPrivacy",
"locationPrivacy",
"socialLinksPrivacy",
"offeringPrivacy",
@ -66,6 +62,20 @@ export default defineEventHandler(async (event) => {
}
});
// Handle offering and lookingFor separately (nested objects)
if (body.offering !== undefined) {
updateData.offering = {
text: body.offering.text || "",
tags: body.offering.tags || [],
};
}
if (body.lookingFor !== undefined) {
updateData.lookingFor = {
text: body.lookingFor.text || "",
tags: body.lookingFor.tags || [],
};
}
// Handle privacy settings
privacyFields.forEach((privacyField) => {
if (body[privacyField] !== undefined) {
@ -100,7 +110,6 @@ export default defineEventHandler(async (event) => {
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
skills: member.skills,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,

View file

@ -0,0 +1,63 @@
import jwt from "jsonwebtoken";
import Member from "../models/member.js";
import { connectDB } from "../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
// Check if user is authenticated (optional for this endpoint)
const token = getCookie(event, "auth-token");
let isAuthenticated = false;
if (token) {
try {
jwt.verify(token, process.env.JWT_SECRET);
isAuthenticated = true;
} catch (err) {
isAuthenticated = false;
}
}
const query = getQuery(event);
const topic = query.topic;
// Build query for peer supporters
const dbQuery = {
"peerSupport.enabled": true,
status: "active",
};
// Filter by topic if specified
if (topic) {
dbQuery["peerSupport.topics"] = topic;
}
try {
const supporters = await Member.find(dbQuery)
.select(
"name avatar circle peerSupport slackUserId createdAt"
)
.sort({ createdAt: -1 })
.lean();
// Get unique topics for filter options
const allTopics = supporters
.flatMap((supporter) => supporter.peerSupport?.topics || [])
.filter((topic, index, self) => self.indexOf(topic) === index)
.sort();
return {
supporters,
totalCount: supporters.length,
filters: {
availableTopics: allTopics,
},
};
} catch (error) {
console.error("Peer support fetch error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch peer supporters",
});
}
});

View file

@ -0,0 +1,28 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({ statusCode: 401, statusMessage: "Not authenticated" });
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
}
const member = await Member.findById(memberId).select("name peerSupport slackUserId");
return {
name: member.name,
peerSupport: member.peerSupport,
slackUserId: member.slackUserId,
};
});

View file

@ -0,0 +1,62 @@
// Migration to fix offering and lookingFor field structure
// Run this once to convert string values to object structure
import mongoose from "mongoose";
import Member from "../models/member.js";
import { connectDB } from "../utils/mongoose.js";
async function migrateOfferingLookingFor() {
await connectDB();
console.log("Starting migration: fixing offering and lookingFor structure...");
try {
// Find all members where offering or lookingFor is a string (not an object)
const members = await Member.find({
$or: [
{ offering: { $type: "string" } },
{ lookingFor: { $type: "string" } },
],
});
console.log(`Found ${members.length} members to migrate`);
for (const member of members) {
const updates = {};
// Convert offering if it's a string
if (typeof member.offering === "string") {
updates.offering = {
text: member.offering,
tags: [],
};
console.log(
`Converting offering for member ${member._id}: "${member.offering}"`,
);
}
// Convert lookingFor if it's a string
if (typeof member.lookingFor === "string") {
updates.lookingFor = {
text: member.lookingFor,
tags: [],
};
console.log(
`Converting lookingFor for member ${member._id}: "${member.lookingFor}"`,
);
}
// Update the member
if (Object.keys(updates).length > 0) {
await Member.findByIdAndUpdate(member._id, { $set: updates });
}
}
console.log("Migration completed successfully!");
process.exit(0);
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
}
}
migrateOfferingLookingFor();

View file

@ -51,7 +51,6 @@ const memberSchema = new mongoose.Schema({
avatar: String,
studio: String,
bio: String,
skills: [String],
location: String,
socialLinks: {
mastodon: String,
@ -59,10 +58,27 @@ const memberSchema = new mongoose.Schema({
website: String,
other: String,
},
offering: String,
lookingFor: String,
offering: {
text: String,
tags: [String],
},
lookingFor: {
text: String,
tags: [String],
},
showInDirectory: { type: Boolean, default: true },
// Peer support settings
peerSupport: {
enabled: { type: Boolean, default: false },
skillTopics: [String], // Auto-populated from offering.tags, editable
supportTopics: [String], // Curated conversational/emotional support topics
availability: String,
personalMessage: String,
slackUsername: String,
slackDMChannelId: String, // DM channel ID for direct messaging
},
// Privacy settings for profile fields
privacy: {
pronouns: {
@ -90,11 +106,6 @@ const memberSchema = new mongoose.Schema({
enum: ["public", "members", "private"],
default: "members",
},
skills: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
location: {
type: String,
enum: ["public", "members", "private"],

View file

@ -14,7 +14,7 @@ export class SlackService {
*/
async inviteUserToSlack(
email: string,
realName: string
realName: string,
): Promise<{
success: boolean;
userId?: string;
@ -34,7 +34,7 @@ export class SlackService {
});
console.log(
`Successfully invited existing user ${email} to vetting channel`
`Successfully invited existing user ${email} to vetting channel`,
);
return {
success: true,
@ -65,7 +65,7 @@ export class SlackService {
if (inviteResponse.ok && inviteResponse.user) {
console.log(
`Successfully invited ${email} to workspace as single-channel guest`
`Successfully invited ${email} to workspace as single-channel guest`,
);
return {
success: true,
@ -79,7 +79,7 @@ export class SlackService {
console.log(
`Admin API not available or failed: ${
adminError.data?.error || adminError.message
}`
}`,
);
// Fall back to manual process
@ -113,6 +113,67 @@ export class SlackService {
}
}
/**
* Find user ID by username (display name or real name)
*/
async findUserIdByUsername(username: string): Promise<string | null> {
try {
const cleanUsername = username.replace("@", "").toLowerCase();
// List all users and search for matching username
const response = await this.client.users.list();
if (!response.members) {
return null;
}
// Search for user by name or display_name
const user = response.members.find((member: any) => {
const name = member.name?.toLowerCase() || "";
const realName = member.real_name?.toLowerCase() || "";
const displayName = member.profile?.display_name?.toLowerCase() || "";
return (
name === cleanUsername ||
displayName === cleanUsername ||
realName.includes(cleanUsername)
);
});
return user?.id || null;
} catch (error) {
console.error("Error looking up Slack user by username:", error);
return null;
}
}
/**
* Open/get a DM channel with a user and return the channel ID
* This creates or opens a DM conversation and returns the channel ID (starts with D)
*/
async openDMChannel(userId: string): Promise<string | null> {
try {
const response = await this.client.conversations.open({
users: userId,
});
if (response.ok && response.channel?.id) {
console.log(
`Opened DM channel for user ${userId}: ${response.channel.id}`,
);
return response.channel.id;
}
return null;
} catch (error: any) {
console.error(
"Error opening DM channel:",
error.data?.error || error.message,
);
return null;
}
}
/**
* Send a notification to the vetting channel about a new member
*/
@ -121,7 +182,7 @@ export class SlackService {
memberEmail: string,
circle: string,
contributionTier: string,
invitationStatus: string = "manual_invitation_required"
invitationStatus: string = "manual_invitation_required",
): Promise<void> {
try {
let statusMessage = "";
@ -224,7 +285,7 @@ export function getSlackService(): SlackService | null {
if (!config.slackBotToken || !config.slackVettingChannelId) {
console.warn(
"Slack integration not configured - missing bot token or channel ID"
"Slack integration not configured - missing bot token or channel ID",
);
return null;
}