Updates to profile

This commit is contained in:
Jennie Robinson Faber 2025-10-06 14:52:03 +01:00
parent 1b8dacf92a
commit 970b185151
16 changed files with 652 additions and 1585 deletions

View file

@ -112,7 +112,6 @@ const memberNavigationItems = [
{ label: "Events", path: "/events" }, { label: "Events", path: "/events" },
{ label: "Members", path: "/members" }, { label: "Members", path: "/members" },
{ label: "Resources", path: "/resources" }, { label: "Resources", path: "/resources" },
{ label: "Updates", path: "/updates" },
{ label: "Peer Support", path: "/peer-support" }, { label: "Peer Support", path: "/peer-support" },
{ label: "Profile", path: "/member/profile" }, { label: "Profile", path: "/member/profile" },
]; ];

View file

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

@ -48,6 +48,7 @@
v-model="formData.name" v-model="formData.name"
placeholder="Your name" placeholder="Your name"
disabled disabled
class="w-full"
/> />
</UFormField> </UFormField>
@ -61,6 +62,7 @@
<UInput <UInput
v-model="formData.pronouns" v-model="formData.pronouns"
placeholder="e.g., she/her, they/them" placeholder="e.g., she/her, they/them"
class="w-full"
/> />
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
@ -78,6 +80,7 @@
<UInput <UInput
v-model="formData.timeZone" v-model="formData.timeZone"
placeholder="e.g., America/Toronto" placeholder="e.g., America/Toronto"
class="w-full"
/> />
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
@ -147,6 +150,7 @@
<UInput <UInput
v-model="formData.studio" v-model="formData.studio"
placeholder="Studio name" placeholder="Studio name"
class="w-full"
/> />
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
@ -164,6 +168,7 @@
<UInput <UInput
v-model="formData.location" v-model="formData.location"
placeholder="Toronto, ON" placeholder="Toronto, ON"
class="w-full"
/> />
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
@ -184,6 +189,7 @@
placeholder="Share your background, interests, and experience..." placeholder="Share your background, interests, and experience..."
:rows="4" :rows="4"
autoresize autoresize
class="w-full"
/> />
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
@ -191,43 +197,6 @@
class="mt-2" class="mt-2"
/> />
</div> </div>
<div>
<UFormField
label="Skills"
name="skills"
description="Add your skills (press Enter or comma to add)"
>
<div class="space-y-3">
<UInput
v-model="currentSkillInput"
placeholder="Type a skill and press Enter"
@keydown.enter.prevent="addSkill"
@keydown.comma.prevent="addSkill"
/>
<div
v-if="formData.skills?.length"
class="flex flex-wrap gap-2"
>
<span
v-for="(skill, index) in formData.skills"
:key="skill"
class="px-3 py-1 bg-blue-500/20 text-blue-300 rounded-full text-sm border border-blue-500/30 flex items-center gap-2 group hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removeSkill(index)"
>
{{ skill }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.skillsPrivacy"
class="mt-2"
/>
</div>
</div> </div>
</div> </div>
@ -246,12 +215,55 @@
name="offering" name="offering"
description="Skills, resources, or support you can offer the community" description="Skills, resources, or support you can offer the community"
> >
<div class="space-y-3">
<!-- Tags input -->
<div>
<label
class="block text-sm font-medium text-stone-200 mb-2"
>
Skills & Topics
</label>
<UInput
v-model="currentOfferingTagInput"
placeholder="Type a skill and press Enter (e.g., Unity, Playtesting)"
@keydown.enter.prevent="addOfferingTag"
@keydown.comma.prevent="addOfferingTag"
class="w-full"
/>
<div
v-if="formData.offering.tags?.length"
class="flex flex-wrap gap-2 mt-2"
>
<span
v-for="(tag, index) in formData.offering.tags"
:key="tag"
class="px-3 py-1 bg-blue-500/20 text-blue-300 rounded-full text-sm border border-blue-500/30 flex items-center gap-2 group hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removeOfferingTag(index)"
>
{{ tag }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
<!-- Description textarea -->
<div>
<label
class="block text-sm font-medium text-stone-200 mb-2"
>
Details
</label>
<UTextarea <UTextarea
v-model="formData.offering" v-model="formData.offering.text"
placeholder="e.g., Mentorship in game design, playtesting, technical advice..." placeholder="e.g., I have 10+ years in Unity and love helping new devs. Can also playtest narrative games."
:rows="3" :rows="3"
autoresize autoresize
class="w-full"
/> />
</div>
</div>
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
v-model="formData.offeringPrivacy" v-model="formData.offeringPrivacy"
@ -265,12 +277,55 @@
name="lookingFor" name="lookingFor"
description="Support, collaboration, or resources you need" description="Support, collaboration, or resources you need"
> >
<div class="space-y-3">
<!-- Tags input -->
<div>
<label
class="block text-sm font-medium text-stone-200 mb-2"
>
Skills & Topics
</label>
<UInput
v-model="currentLookingForTagInput"
placeholder="Type what you need and press Enter (e.g., Co-founder, Marketing)"
@keydown.enter.prevent="addLookingForTag"
@keydown.comma.prevent="addLookingForTag"
class="w-full"
/>
<div
v-if="formData.lookingFor.tags?.length"
class="flex flex-wrap gap-2 mt-2"
>
<span
v-for="(tag, index) in formData.lookingFor.tags"
:key="tag"
class="px-3 py-1 bg-purple-500/20 text-purple-300 rounded-full text-sm border border-purple-500/30 flex items-center gap-2 group hover:bg-purple-500/30 transition-colors cursor-pointer"
@click="removeLookingForTag(index)"
>
{{ tag }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
<!-- Description textarea -->
<div>
<label
class="block text-sm font-medium text-stone-200 mb-2"
>
Details
</label>
<UTextarea <UTextarea
v-model="formData.lookingFor" v-model="formData.lookingFor.text"
placeholder="e.g., Co-founder for studio, help with publishing, feedback on project..." placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio. Also need help understanding the publishing landscape."
:rows="3" :rows="3"
autoresize autoresize
class="w-full"
/> />
</div>
</div>
</UFormField> </UFormField>
<PrivacyToggle <PrivacyToggle
v-model="formData.lookingForPrivacy" v-model="formData.lookingForPrivacy"
@ -280,49 +335,176 @@
</div> </div>
</div> </div>
<!-- Social Links --> <!-- Peer Support -->
<div> <div>
<h2 <h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text" class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
> >
Social Links Peer Support
</h2> </h2>
<div
class="mb-6 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-4"
>
<p class="text-stone-300 text-sm leading-relaxed">
Offer guidance to fellow members through the
<NuxtLink
to="/peer-support"
class="text-purple-400 hover:text-purple-300 underline"
>
Peer Support directory
</NuxtLink>
</p>
</div>
<div class="space-y-6"> <div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- Enable Toggle -->
<UFormField label="Mastodon" name="mastodon"> <div class="flex items-start gap-4">
<USwitch v-model="formData.peerSupportEnabled" />
<div>
<p class="font-medium text-stone-200">
Offer Peer Support
</p>
<p class="text-sm text-stone-400 mt-1">
Make yourself available to support other members
</p>
</div>
</div>
<!-- Conditional Fields -->
<div
v-if="formData.peerSupportEnabled"
class="space-y-6 pl-4 border-l-2 border-purple-500/30"
>
<!-- Skill-Based Topics -->
<UFormField
label="Skill-Based Topics"
name="peerSupportSkillTopics"
description="Skills from your offerings where you can provide guidance"
>
<div class="space-y-3">
<UInput <UInput
v-model="formData.socialLinks.mastodon" v-model="currentPeerSkillTopicInput"
placeholder="@username@instance.social" placeholder="Type a skill and press Enter"
@keydown.enter.prevent="addPeerSkillTopic"
@keydown.comma.prevent="addPeerSkillTopic"
class="w-full"
/>
<div
v-if="formData.peerSupportSkillTopics?.length"
class="flex flex-wrap gap-2"
>
<span
v-for="(
topic, index
) in formData.peerSupportSkillTopics"
:key="topic"
class="px-3 py-1 bg-blue-500/20 text-blue-300 rounded-full text-sm border border-blue-500/30 flex items-center gap-2 group hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removePeerSkillTopic(index)"
>
{{ topic }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
<p class="text-xs text-stone-500 mt-2">
Suggested from your offerings:
<span
v-for="tag in formData.offering.tags?.filter(
(t) =>
!formData.peerSupportSkillTopics?.includes(t),
)"
:key="tag"
class="inline-block ml-2 text-blue-400 hover:text-blue-300 cursor-pointer underline"
@click="addSuggestedSkillTopic(tag)"
>
{{ tag }}
</span>
</p>
</div>
</UFormField>
<!-- Conversational Support Topics -->
<UFormField
label="Conversational Support Topics"
name="peerSupportSupportTopics"
description="Select emotional and conversational support areas"
>
<div class="space-y-2 mt-2">
<label
v-for="topic in availableSupportTopics"
:key="topic"
class="flex items-center gap-3 p-3 rounded-lg border border-stone-700 hover:border-purple-500/50 transition-colors cursor-pointer"
:class="
formData.peerSupportSupportTopics.includes(topic)
? 'bg-purple-500/10 border-purple-500/50'
: 'bg-stone-900/50'
"
>
<input
type="checkbox"
:value="topic"
v-model="formData.peerSupportSupportTopics"
class="rounded border-stone-600 text-purple-500 focus:ring-purple-500 focus:ring-offset-0 bg-stone-800"
/>
<span class="text-stone-200">{{ topic }}</span>
</label>
</div>
</UFormField>
<!-- Availability -->
<UFormField
label="Your Availability"
name="peerSupportAvailability"
description="When are you generally available for peer support sessions?"
required
>
<UTextarea
v-model="formData.peerSupportAvailability"
placeholder="e.g., Weekday evenings (6-9pm EST), weekends flexible, or by appointment"
rows="3"
autoresize
class="w-full"
/> />
</UFormField> </UFormField>
<UFormField label="LinkedIn" name="linkedin"> <!-- Personal Message -->
<UInput <UFormField
v-model="formData.socialLinks.linkedin" label="Personal Message to Potential Chatters"
placeholder="https://linkedin.com/in/username" name="peerSupportMessage"
description="A friendly message to encourage people to reach out"
>
<UTextarea
v-model="formData.peerSupportMessage"
placeholder="e.g., I love talking about this stuff! No question is too basic."
rows="2"
maxlength="200"
autoresize
class="w-full"
/> />
<template #hint>
<span class="text-xs text-stone-500">
{{ formData.peerSupportMessage?.length || 0 }}/200
characters
</span>
</template>
</UFormField> </UFormField>
<UFormField label="Website" name="website"> <!-- Slack Username -->
<UFormField
label="Slack Username"
name="peerSupportSlackUsername"
description="Your Slack username so members can message you"
required
>
<UInput <UInput
v-model="formData.socialLinks.website" v-model="formData.peerSupportSlackUsername"
placeholder="https://yourwebsite.com" placeholder="@yourslackname"
/> class="w-full"
</UFormField>
<UFormField label="Other" name="other">
<UInput
v-model="formData.socialLinks.other"
placeholder="Any other relevant link"
/> />
</UFormField> </UFormField>
</div> </div>
<PrivacyToggle
v-model="formData.socialLinksPrivacy"
label="Privacy for all social links"
/>
</div> </div>
</div> </div>
@ -389,181 +571,6 @@
</UForm> </UForm>
</template> </template>
<template #updates>
<div class="mt-8">
<!-- Loading State -->
<div
v-if="loadingUpdates && !myUpdates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your updates...</p>
</div>
</div>
<!-- Updates List -->
<div v-else-if="myUpdates.length" class="space-y-6">
<UpdateCard
v-for="update in myUpdates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEditUpdate"
@delete="handleDeleteUpdate"
/>
<!-- Load More -->
<div v-if="hasMoreUpdates" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMoreUpdates"
@click="loadMoreUpdates"
>
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">
Share your first update with the community!
</p>
<UButton to="/updates/new"> Post Your First Update </UButton>
</div>
</div>
</template>
<template #peer-support>
<div class="space-y-8 mt-8 max-w-4xl">
<div
class="backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6"
>
<h3 class="text-lg font-semibold text-purple-200 mb-2">
About Peer Support
</h3>
<p class="text-stone-300 text-sm leading-relaxed mb-4">
Peer support allows you to offer guidance to fellow members
or find peers who can help you. Enable peer support to
appear in the directory, or browse available supporters.
</p>
<div class="flex gap-3 flex-wrap">
<UButton to="/member/settings/peer-support">
Configure Peer Support
</UButton>
<UButton to="/peer-support" variant="outline">
Browse Peer Supporters
</UButton>
</div>
</div>
<!-- Current Status -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-stone-100 ethereal-text"
>
Your Peer Support Status
</h2>
<div
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700 rounded-lg p-6"
>
<div class="space-y-4">
<div>
<p class="text-sm text-stone-400">Status</p>
<p class="text-lg font-medium">
<span
v-if="memberData.peerSupport?.enabled"
class="text-green-400"
>
Active - You're offering peer support
</span>
<span v-else class="text-stone-400">
Not currently offering peer support
</span>
</p>
</div>
<div v-if="memberData.peerSupport?.enabled">
<div
v-if="memberData.peerSupport.topics?.length"
class="mb-4"
>
<p class="text-sm text-stone-400 mb-2">
Topics you help with:
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="topic in memberData.peerSupport.topics"
:key="topic"
class="px-3 py-1 bg-purple-500/20 text-purple-300 rounded-full text-sm border border-purple-500/30"
>
{{ topic }}
</span>
</div>
</div>
<div v-if="memberData.peerSupport.availability">
<p class="text-sm text-stone-400 mb-1">
Your availability:
</p>
<p class="text-stone-200">
{{ memberData.peerSupport.availability }}
</p>
</div>
<div
v-if="memberData.peerSupport.personalMessage"
class="mt-4"
>
<p class="text-sm text-stone-400 mb-1">
Your personal message:
</p>
<p class="text-stone-200 italic">
"{{ memberData.peerSupport.personalMessage }}"
</p>
</div>
<div
v-if="memberData.peerSupport.slackUsername"
class="mt-4"
>
<p class="text-sm text-stone-400 mb-1">
Slack username:
</p>
<p class="text-stone-200">
{{ memberData.peerSupport.slackUsername }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template #account> <template #account>
<div class="space-y-8 mt-8"> <div class="space-y-8 mt-8">
<!-- Current Membership --> <!-- Current Membership -->
@ -750,32 +757,6 @@
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Delete Update Modal -->
<UModal
v-model:open="showDeleteUpdateModal"
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="showDeleteUpdateModal = false"
>
Cancel
</UButton>
<UButton
color="red"
:loading="deletingUpdate"
@click="confirmDeleteUpdate"
>
Delete
</UButton>
</div>
</template>
</UModal>
</div> </div>
</UContainer> </UContainer>
</section> </section>
@ -816,16 +797,6 @@ const tabItems = [
slot: "profile", slot: "profile",
value: "profile", value: "profile",
}, },
{
label: "My Updates",
slot: "updates",
value: "updates",
},
{
label: "Peer Support",
slot: "peer-support",
value: "peer-support",
},
{ {
label: "Account", label: "Account",
slot: "account", slot: "account",
@ -875,45 +846,54 @@ const formData = reactive({
avatar: "", avatar: "",
studio: "", studio: "",
bio: "", bio: "",
skills: [],
location: "", location: "",
offering: "", offering: {
lookingFor: "", text: "",
socialLinks: { tags: [],
mastodon: "", },
linkedin: "", lookingFor: {
website: "", text: "",
other: "", tags: [],
}, },
showInDirectory: true, showInDirectory: true,
// Peer support fields
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: "",
peerSupportMessage: "",
peerSupportSlackUsername: "",
// Privacy settings // Privacy settings
pronounsPrivacy: "members", pronounsPrivacy: "members",
timeZonePrivacy: "members", timeZonePrivacy: "members",
avatarPrivacy: "public", avatarPrivacy: "members",
studioPrivacy: "members", studioPrivacy: "members",
bioPrivacy: "members", bioPrivacy: "members",
skillsPrivacy: "members",
locationPrivacy: "members", locationPrivacy: "members",
offeringPrivacy: "members", offeringPrivacy: "members",
lookingForPrivacy: "members", lookingForPrivacy: "members",
socialLinksPrivacy: "members",
}); });
const currentSkillInput = ref(""); // Tag input refs
const currentOfferingTagInput = ref("");
const currentLookingForTagInput = ref("");
const currentPeerSkillTopicInput = ref("");
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const saveSuccess = ref(false); const saveSuccess = ref(false);
const saveError = ref(null); const saveError = ref(null);
const initialData = ref(null); const initialData = ref(null);
// Updates state // Available conversational support topics for peer support
const myUpdates = ref([]); const availableSupportTopics = [
const loadingUpdates = ref(false); "Co-founder relationships",
const loadingMoreUpdates = ref(false); "Burnout prevention",
const hasMoreUpdates = ref(false); "Impostor syndrome",
const showDeleteUpdateModal = ref(false); "Work-life boundaries",
const updateToDelete = ref(null); "Conflict resolution",
const deletingUpdate = ref(false); "General chat & support",
];
// Account management state // Account management state
const contributionOptions = ref([]); const contributionOptions = ref([]);
@ -944,18 +924,49 @@ const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value); return JSON.stringify(formData) !== JSON.stringify(initialData.value);
}); });
// Add skill // Offering tag management
const addSkill = () => { const addOfferingTag = () => {
const skill = currentSkillInput.value.trim().replace(/,$/, ""); const tag = currentOfferingTagInput.value.trim().replace(/,$/, "");
if (skill && !formData.skills.includes(skill)) { if (tag && !formData.offering.tags.includes(tag)) {
formData.skills.push(skill); formData.offering.tags.push(tag);
currentSkillInput.value = ""; currentOfferingTagInput.value = "";
} }
}; };
// Remove skill const removeOfferingTag = (index) => {
const removeSkill = (index) => { formData.offering.tags.splice(index, 1);
formData.skills.splice(index, 1); };
// Looking For tag management
const addLookingForTag = () => {
const tag = currentLookingForTagInput.value.trim().replace(/,$/, "");
if (tag && !formData.lookingFor.tags.includes(tag)) {
formData.lookingFor.tags.push(tag);
currentLookingForTagInput.value = "";
}
};
const removeLookingForTag = (index) => {
formData.lookingFor.tags.splice(index, 1);
};
// Peer support skill topic management
const addPeerSkillTopic = () => {
const topic = currentPeerSkillTopicInput.value.trim().replace(/,$/, "");
if (topic && !formData.peerSupportSkillTopics.includes(topic)) {
formData.peerSupportSkillTopics.push(topic);
currentPeerSkillTopicInput.value = "";
}
};
const removePeerSkillTopic = (index) => {
formData.peerSupportSkillTopics.splice(index, 1);
};
const addSuggestedSkillTopic = (tag) => {
if (!formData.peerSupportSkillTopics.includes(tag)) {
formData.peerSupportSkillTopics.push(tag);
}
}; };
// Load member data // Load member data
@ -967,30 +978,56 @@ const loadProfile = () => {
formData.avatar = memberData.value.avatar || ""; formData.avatar = memberData.value.avatar || "";
formData.studio = memberData.value.studio || ""; formData.studio = memberData.value.studio || "";
formData.bio = memberData.value.bio || ""; formData.bio = memberData.value.bio || "";
formData.skills = memberData.value.skills || [];
formData.location = memberData.value.location || ""; formData.location = memberData.value.location || "";
formData.offering = memberData.value.offering || "";
formData.lookingFor = memberData.value.lookingFor || ""; // Load offering (handle both old string and new object format)
formData.socialLinks = memberData.value.socialLinks || { if (typeof memberData.value.offering === "string") {
mastodon: "", formData.offering = { text: memberData.value.offering, tags: [] };
linkedin: "", } else {
website: "", formData.offering = {
other: "", text: memberData.value.offering?.text || "",
tags: memberData.value.offering?.tags || [],
}; };
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === "string") {
formData.lookingFor = { text: memberData.value.lookingFor, tags: [] };
} else {
formData.lookingFor = {
text: memberData.value.lookingFor?.text || "",
tags: memberData.value.lookingFor?.tags || [],
};
}
formData.showInDirectory = memberData.value.showInDirectory ?? true; formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load peer support data
if (memberData.value.peerSupport) {
formData.peerSupportEnabled =
memberData.value.peerSupport.enabled || false;
formData.peerSupportSkillTopics =
memberData.value.peerSupport.skillTopics || [];
formData.peerSupportSupportTopics =
memberData.value.peerSupport.supportTopics || [];
formData.peerSupportAvailability =
memberData.value.peerSupport.availability || "";
formData.peerSupportMessage =
memberData.value.peerSupport.personalMessage || "";
formData.peerSupportSlackUsername =
memberData.value.peerSupport.slackUsername || "";
}
// Load privacy settings (with defaults) // Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {}; const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members"; formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members"; formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "public"; formData.avatarPrivacy = privacy.avatar || "members";
formData.studioPrivacy = privacy.studio || "members"; formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members"; formData.bioPrivacy = privacy.bio || "members";
formData.skillsPrivacy = privacy.skills || "members";
formData.locationPrivacy = privacy.location || "members"; formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members"; formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "members"; formData.lookingForPrivacy = privacy.lookingFor || "members";
formData.socialLinksPrivacy = privacy.socialLinks || "members";
// Store initial state for change detection // Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData)); initialData.value = JSON.parse(JSON.stringify(formData));
@ -1004,11 +1041,25 @@ const handleSubmit = async () => {
saveError.value = null; saveError.value = null;
try { try {
// Save profile data
await $fetch("/api/members/profile", { await $fetch("/api/members/profile", {
method: "PATCH", method: "PATCH",
body: formData, body: formData,
}); });
// Save peer support data separately
await $fetch("/api/members/me/peer-support", {
method: "PATCH",
body: {
enabled: formData.peerSupportEnabled,
skillTopics: formData.peerSupportSkillTopics,
supportTopics: formData.peerSupportSupportTopics,
availability: formData.peerSupportAvailability,
personalMessage: formData.peerSupportMessage,
slackUsername: formData.peerSupportSlackUsername,
},
});
saveSuccess.value = true; saveSuccess.value = true;
// Refresh member data // Refresh member data
@ -1035,78 +1086,6 @@ const resetForm = () => {
saveError.value = null; saveError.value = null;
}; };
// Load user's updates
const loadUpdates = async () => {
if (!memberData.value?.id) return;
loadingUpdates.value = true;
try {
const response = await $fetch(`/api/updates/user/${memberData.value.id}`, {
params: { limit: 20, skip: 0 },
});
myUpdates.value = response.updates;
hasMoreUpdates.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
loadingUpdates.value = false;
}
};
// Load more updates
const loadMoreUpdates = async () => {
if (!memberData.value?.id) return;
loadingMoreUpdates.value = true;
try {
const response = await $fetch(`/api/updates/user/${memberData.value.id}`, {
params: { limit: 20, skip: myUpdates.value.length },
});
myUpdates.value.push(...response.updates);
hasMoreUpdates.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMoreUpdates.value = false;
}
};
// Handle edit update
const handleEditUpdate = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete update
const handleDeleteUpdate = (update) => {
updateToDelete.value = update;
showDeleteUpdateModal.value = true;
};
// Confirm delete update
const confirmDeleteUpdate = async () => {
if (!updateToDelete.value) return;
deletingUpdate.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
myUpdates.value = myUpdates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
showDeleteUpdateModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deletingUpdate.value = false;
}
};
// Format date helper // Format date helper
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return ""; if (!dateString) return "";
@ -1334,7 +1313,6 @@ onMounted(async () => {
} }
loadProfile(); loadProfile();
loadUpdates();
loadContributionOptions(); loadContributionOptions();
selectedContributionTier.value = memberData.value?.contributionTier || ""; selectedContributionTier.value = memberData.value?.contributionTier || "";
}); });
@ -1349,6 +1327,7 @@ useHead({
:deep(label) { :deep(label) {
color: rgb(231 229 228) !important; /* stone-200 */ color: rgb(231 229 228) !important; /* stone-200 */
font-weight: 500; font-weight: 500;
text-align: left !important;
} }
/* Field descriptions - lighter gray for readability */ /* Field descriptions - lighter gray for readability */

View file

@ -1,366 +0,0 @@
<template>
<div>
<PageHeader
title="Peer Support Settings"
subtitle="Offer guidance and support to fellow Ghost Guild members"
theme="purple"
size="medium"
/>
<section class="py-12">
<UContainer>
<div
v-if="loading && !formData"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading settings...</p>
</div>
</div>
<div v-else class="max-w-3xl">
<!-- Info Box -->
<div
class="mb-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6"
>
<h3 class="text-lg font-semibold text-purple-200 mb-2">
About Peer Support
</h3>
<p class="text-stone-300 text-sm leading-relaxed">
Peer support is a way to share your knowledge and experience with
fellow members. When enabled, you'll appear in the
<NuxtLink
to="/peer-support"
class="text-purple-400 hover:text-purple-300 underline"
>
Peer Support directory
</NuxtLink>
where members can reach out to you for guidance on topics you're
comfortable discussing.
</p>
</div>
<!-- Form -->
<UForm :state="formData" @submit="handleSubmit" class="space-y-8">
<!-- Enable Toggle -->
<UFormField
label="Offer Peer Support"
name="enabled"
description="Make yourself available to support other members"
>
<USwitch v-model="formData.enabled" size="lg" />
</UFormField>
<!-- Conditional Fields -->
<div
v-if="formData.enabled"
class="space-y-6 pl-4 border-l-2 border-purple-500/30"
>
<!-- Topics -->
<UFormField
label="Topics You Can Help With"
name="topics"
description="Select all topics where you can offer guidance"
required
>
<div class="space-y-2 mt-2">
<label
v-for="topic in availableTopics"
:key="topic"
class="flex items-center gap-3 p-3 rounded-lg border border-stone-700 hover:border-purple-500/50 transition-colors cursor-pointer"
:class="
formData.topics.includes(topic)
? 'bg-purple-500/10 border-purple-500/50'
: 'bg-stone-900/50'
"
>
<input
type="checkbox"
:value="topic"
v-model="formData.topics"
class="rounded border-stone-600 text-purple-500 focus:ring-purple-500 focus:ring-offset-0 bg-stone-800"
/>
<span class="text-stone-200">{{ topic }}</span>
</label>
</div>
</UFormField>
<!-- Availability -->
<UFormField
label="Your Availability"
name="availability"
description="When are you generally available for peer support sessions?"
required
>
<UTextarea
v-model="formData.availability"
placeholder="e.g., Weekday evenings (6-9pm EST), weekends flexible, or by appointment"
rows="3"
/>
</UFormField>
<!-- Personal Message -->
<UFormField
label="Personal Message to Potential Chatters"
name="personalMessage"
description="A friendly message to encourage people to reach out"
>
<UTextarea
v-model="formData.personalMessage"
placeholder="e.g., I love talking about this stuff! No question is too basic."
rows="2"
maxlength="200"
/>
<template #hint>
<span class="text-xs text-stone-500">
{{ formData.personalMessage?.length || 0 }}/200 characters
</span>
</template>
</UFormField>
<!-- Slack Username -->
<UFormField
label="Slack Username"
name="slackUsername"
description="Your Slack username so members can message you (required)"
required
>
<UInput
v-model="formData.slackUsername"
placeholder="@yourslackname"
/>
</UFormField>
<!-- Preview -->
<div
class="mt-8 p-6 bg-stone-900/50 border border-stone-700/50 rounded-lg"
>
<h3 class="text-sm font-semibold text-stone-400 mb-4">
Preview: How you'll appear in the directory
</h3>
<div
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700/50 rounded-lg p-6"
>
<div class="flex items-center gap-3 mb-4">
<div
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0"
>
<span class="text-xl">👻</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-stone-100">
{{ memberData?.name || "Your Name" }}
</h3>
<span
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30 inline-block"
>
{{
memberData?.circle
? circleLabels[memberData.circle]
: "Your Circle"
}}
</span>
</div>
</div>
<div v-if="formData.topics.length > 0" class="mb-4">
<div class="text-xs text-stone-500 mb-2">Topics:</div>
<div class="flex flex-wrap gap-1">
<span
v-for="topic in formData.topics"
:key="topic"
class="px-2 py-0.5 bg-stone-800/50 text-stone-300 rounded text-xs border border-stone-700"
>
{{ topic }}
</span>
</div>
</div>
<div
v-if="formData.availability"
class="mb-4 text-sm text-stone-400"
>
<div class="text-xs text-stone-500 mb-1">Availability:</div>
{{ formData.availability }}
</div>
<div
v-if="formData.personalMessage"
class="mb-4 text-sm text-stone-300 italic"
>
"{{ formData.personalMessage }}"
</div>
<div
v-if="formData.slackUsername"
class="text-sm text-stone-400"
>
<div class="text-xs text-stone-500 mb-1">Slack:</div>
{{ formData.slackUsername }}
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 pt-6 border-t border-stone-700">
<UButton
type="submit"
:loading="saving"
:disabled="
formData.enabled &&
(!formData.topics.length ||
!formData.availability ||
!formData.slackUsername)
"
>
Save Settings
</UButton>
<UButton variant="outline" @click="resetForm" :disabled="saving">
Cancel
</UButton>
</div>
</UForm>
<!-- Success Message -->
<div
v-if="showSuccess"
class="mt-6 p-4 bg-green-500/10 border border-green-500/30 rounded-lg text-green-300 text-sm"
>
Peer support settings saved successfully!
<NuxtLink
to="/peer-support"
class="underline hover:text-green-200 ml-2"
>
View the directory
</NuxtLink>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mt-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-300 text-sm"
>
{{ error }}
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
definePageMeta({
middleware: "auth",
});
const { isAuthenticated, memberData, fetchMember } = useAuth();
const { updateSettings } = usePeerSupport();
// State
const loading = ref(true);
const saving = ref(false);
const showSuccess = ref(false);
const error = ref(null);
// Available topics
const availableTopics = [
"Governance & decision-making",
"Financial modeling",
"Fundraising",
"Team building",
"Legal structures",
"Marketing & outreach",
"Conflict resolution",
"General chat & support",
];
// Circle labels
const circleLabels = {
community: "Community",
founder: "Founder",
practitioner: "Practitioner",
};
// Form data
const formData = ref({
enabled: false,
topics: [],
availability: "",
personalMessage: "",
slackUsername: "",
});
// Initialize form with existing data
const initializeForm = () => {
if (memberData.value?.peerSupport) {
formData.value = {
enabled: memberData.value.peerSupport.enabled || false,
topics: memberData.value.peerSupport.topics || [],
availability: memberData.value.peerSupport.availability || "",
personalMessage: memberData.value.peerSupport.personalMessage || "",
slackUsername: memberData.value.peerSupport.slackUsername || "",
};
}
loading.value = false;
};
// Reset form
const resetForm = () => {
initializeForm();
showSuccess.value = false;
error.value = null;
};
// Handle form submission
const handleSubmit = async () => {
saving.value = true;
error.value = null;
showSuccess.value = false;
try {
await updateSettings({
enabled: formData.value.enabled,
topics: formData.value.topics,
availability: formData.value.availability,
personalMessage: formData.value.personalMessage,
slackUsername: formData.value.slackUsername,
});
// Refresh member data
await fetchMember();
showSuccess.value = true;
// Scroll to top to show success message
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) {
console.error("Failed to update peer support settings:", err);
error.value = "Failed to save settings. Please try again.";
} finally {
saving.value = false;
}
};
// Load on mount
onMounted(async () => {
if (!memberData.value) {
await fetchMember();
}
initializeForm();
});
useHead({
title: "Peer Support Settings - Ghost Guild",
meta: [
{
name: "description",
content:
"Configure your peer support settings and offer guidance to Ghost Guild members.",
},
],
});
</script>

View file

@ -34,13 +34,13 @@
</div> </div>
<!-- Skills Filter --> <!-- Skills Filter -->
<div v-if="availableSkills.length > 0"> <div v-if="availableSkills && availableSkills.length > 0">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span class="text-sm text-stone-400 mr-2 self-center" <span class="text-sm text-stone-400 mr-2 self-center"
>Filter by skill:</span >Filter by skill:</span
> >
<button <button
v-for="skill in availableSkills.slice( v-for="skill in (availableSkills || []).slice(
0, 0,
showAllSkills ? undefined : 10, showAllSkills ? undefined : 10,
)" )"
@ -57,7 +57,7 @@
{{ skill }} {{ skill }}
</button> </button>
<button <button
v-if="availableSkills.length > 10" v-if="availableSkills && availableSkills.length > 10"
type="button" type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300" class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills" @click="showAllSkills = !showAllSkills"
@ -300,7 +300,7 @@ const { isAuthenticated } = useAuth();
const members = ref([]); const members = ref([]);
const totalCount = ref(0); const totalCount = ref(0);
const availableSkills = ref([]); const availableSkills = ref([]);
const loading = ref(false); const loading = ref(true); // Start with loading true
const searchQuery = ref(""); const searchQuery = ref("");
const selectedCircle = ref(""); const selectedCircle = ref("");
const selectedSkills = ref([]); const selectedSkills = ref([]);
@ -339,11 +339,14 @@ const loadMembers = async () => {
const data = await $fetch("/api/members/directory", { params }); const data = await $fetch("/api/members/directory", { params });
members.value = data.members; members.value = data.members || [];
totalCount.value = data.totalCount; totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters.availableSkills; availableSkills.value = data.filters?.availableSkills || [];
} catch (error) { } catch (error) {
console.error("Failed to load members:", error); console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
} }

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,7 +41,6 @@ export default defineEventHandler(async (event) => {
avatar: member.avatar, avatar: member.avatar,
studio: member.studio, studio: member.studio,
bio: member.bio, bio: member.bio,
skills: member.skills,
location: member.location, location: member.location,
socialLinks: member.socialLinks, socialLinks: member.socialLinks,
offering: member.offering, offering: member.offering,

View file

@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
const search = query.search || ""; const search = query.search || "";
const circle = query.circle || ""; const circle = query.circle || "";
const skills = query.skills ? query.skills.split(",") : []; const tags = query.tags ? query.tags.split(",") : [];
// Build query // Build query
const dbQuery = { const dbQuery = {
@ -45,15 +45,36 @@ export default defineEventHandler(async (event) => {
]; ];
} }
// Filter by skills // Filter by tags (search in offering.tags or lookingFor.tags)
if (skills.length > 0) { if (tags.length > 0) {
dbQuery.skills = { $in: skills }; 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;
}
} }
try { try {
const members = await Member.find(dbQuery) const members = await Member.find(dbQuery)
.select( .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 createdAt",
) )
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean(); .lean();
@ -83,7 +104,6 @@ export default defineEventHandler(async (event) => {
if (isVisible("timeZone")) filtered.timeZone = member.timeZone; if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
if (isVisible("studio")) filtered.studio = member.studio; if (isVisible("studio")) filtered.studio = member.studio;
if (isVisible("bio")) filtered.bio = member.bio; if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("skills")) filtered.skills = member.skills;
if (isVisible("location")) filtered.location = member.location; if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks; if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering; if (isVisible("offering")) filtered.offering = member.offering;
@ -92,17 +112,20 @@ export default defineEventHandler(async (event) => {
return filtered; return filtered;
}); });
// Get unique skills for filter options // Get unique tags for filter options (from both offering and lookingFor)
const allSkills = members const allTags = members
.flatMap((m) => m.skills || []) .flatMap((m) => [
.filter((skill, index, self) => self.indexOf(skill) === index) ...(m.offering?.tags || []),
...(m.lookingFor?.tags || []),
])
.filter((tag, index, self) => self.indexOf(tag) === index)
.sort(); .sort();
return { return {
members: filteredMembers, members: filteredMembers,
totalCount: filteredMembers.length, totalCount: filteredMembers.length,
filters: { filters: {
availableSkills: allSkills, availableTags: allTags,
}, },
}; };
} catch (error) { } catch (error) {

View file

@ -30,7 +30,8 @@ export default defineEventHandler(async (event) => {
// Build update object for peer support settings // Build update object for peer support settings
const updateData = { const updateData = {
"peerSupport.enabled": body.enabled || false, "peerSupport.enabled": body.enabled || false,
"peerSupport.topics": body.topics || [], "peerSupport.skillTopics": body.skillTopics || [],
"peerSupport.supportTopics": body.supportTopics || [],
"peerSupport.availability": body.availability || "", "peerSupport.availability": body.availability || "",
"peerSupport.personalMessage": body.personalMessage || "", "peerSupport.personalMessage": body.personalMessage || "",
"peerSupport.slackUsername": body.slackUsername || "", "peerSupport.slackUsername": body.slackUsername || "",

View file

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

View file

@ -51,7 +51,6 @@ const memberSchema = new mongoose.Schema({
avatar: String, avatar: String,
studio: String, studio: String,
bio: String, bio: String,
skills: [String],
location: String, location: String,
socialLinks: { socialLinks: {
mastodon: String, mastodon: String,
@ -59,14 +58,21 @@ const memberSchema = new mongoose.Schema({
website: String, website: String,
other: String, other: String,
}, },
offering: String, offering: {
lookingFor: String, text: String,
tags: [String],
},
lookingFor: {
text: String,
tags: [String],
},
showInDirectory: { type: Boolean, default: true }, showInDirectory: { type: Boolean, default: true },
// Peer support settings // Peer support settings
peerSupport: { peerSupport: {
enabled: { type: Boolean, default: false }, enabled: { type: Boolean, default: false },
topics: [String], skillTopics: [String], // Auto-populated from offering.tags, editable
supportTopics: [String], // Curated conversational/emotional support topics
availability: String, availability: String,
personalMessage: String, personalMessage: String,
slackUsername: String, slackUsername: String,
@ -100,11 +106,6 @@ const memberSchema = new mongoose.Schema({
enum: ["public", "members", "private"], enum: ["public", "members", "private"],
default: "members", default: "members",
}, },
skills: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
location: { location: {
type: String, type: String,
enum: ["public", "members", "private"], enum: ["public", "members", "private"],