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: "Members", path: "/members" },
{ label: "Resources", path: "/resources" },
{ label: "Updates", path: "/updates" },
{ label: "Peer Support", path: "/peer-support" },
{ label: "Profile", path: "/member/profile" },
];

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-stone-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; /* 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"
placeholder="Your name"
disabled
class="w-full"
/>
</UFormField>
@ -61,6 +62,7 @@
<UInput
v-model="formData.pronouns"
placeholder="e.g., she/her, they/them"
class="w-full"
/>
</UFormField>
<PrivacyToggle
@ -78,6 +80,7 @@
<UInput
v-model="formData.timeZone"
placeholder="e.g., America/Toronto"
class="w-full"
/>
</UFormField>
<PrivacyToggle
@ -147,6 +150,7 @@
<UInput
v-model="formData.studio"
placeholder="Studio name"
class="w-full"
/>
</UFormField>
<PrivacyToggle
@ -164,6 +168,7 @@
<UInput
v-model="formData.location"
placeholder="Toronto, ON"
class="w-full"
/>
</UFormField>
<PrivacyToggle
@ -184,6 +189,7 @@
placeholder="Share your background, interests, and experience..."
:rows="4"
autoresize
class="w-full"
/>
</UFormField>
<PrivacyToggle
@ -191,43 +197,6 @@
class="mt-2"
/>
</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>
@ -246,12 +215,55 @@
name="offering"
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
v-model="formData.offering"
placeholder="e.g., Mentorship in game design, playtesting, technical advice..."
v-model="formData.offering.text"
placeholder="e.g., I have 10+ years in Unity and love helping new devs. Can also playtest narrative games."
:rows="3"
autoresize
class="w-full"
/>
</div>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.offeringPrivacy"
@ -265,12 +277,55 @@
name="lookingFor"
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
v-model="formData.lookingFor"
placeholder="e.g., Co-founder for studio, help with publishing, feedback on project..."
v-model="formData.lookingFor.text"
placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio. Also need help understanding the publishing landscape."
:rows="3"
autoresize
class="w-full"
/>
</div>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.lookingForPrivacy"
@ -280,49 +335,176 @@
</div>
</div>
<!-- Social Links -->
<!-- Peer Support -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Social Links
Peer Support
</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="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Mastodon" name="mastodon">
<!-- Enable Toggle -->
<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
v-model="formData.socialLinks.mastodon"
placeholder="@username@instance.social"
v-model="currentPeerSkillTopicInput"
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 label="LinkedIn" name="linkedin">
<UInput
v-model="formData.socialLinks.linkedin"
placeholder="https://linkedin.com/in/username"
<!-- Personal Message -->
<UFormField
label="Personal Message to Potential Chatters"
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 label="Website" name="website">
<!-- Slack Username -->
<UFormField
label="Slack Username"
name="peerSupportSlackUsername"
description="Your Slack username so members can message you"
required
>
<UInput
v-model="formData.socialLinks.website"
placeholder="https://yourwebsite.com"
/>
</UFormField>
<UFormField label="Other" name="other">
<UInput
v-model="formData.socialLinks.other"
placeholder="Any other relevant link"
v-model="formData.peerSupportSlackUsername"
placeholder="@yourslackname"
class="w-full"
/>
</UFormField>
</div>
<PrivacyToggle
v-model="formData.socialLinksPrivacy"
label="Privacy for all social links"
/>
</div>
</div>
@ -389,181 +571,6 @@
</UForm>
</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>
<div class="space-y-8 mt-8">
<!-- Current Membership -->
@ -750,32 +757,6 @@
</div>
</template>
</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>
</UContainer>
</section>
@ -816,16 +797,6 @@ const tabItems = [
slot: "profile",
value: "profile",
},
{
label: "My Updates",
slot: "updates",
value: "updates",
},
{
label: "Peer Support",
slot: "peer-support",
value: "peer-support",
},
{
label: "Account",
slot: "account",
@ -875,45 +846,54 @@ const formData = reactive({
avatar: "",
studio: "",
bio: "",
skills: [],
location: "",
offering: "",
lookingFor: "",
socialLinks: {
mastodon: "",
linkedin: "",
website: "",
other: "",
offering: {
text: "",
tags: [],
},
lookingFor: {
text: "",
tags: [],
},
showInDirectory: true,
// Peer support fields
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: "",
peerSupportMessage: "",
peerSupportSlackUsername: "",
// Privacy settings
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "public",
avatarPrivacy: "members",
studioPrivacy: "members",
bioPrivacy: "members",
skillsPrivacy: "members",
locationPrivacy: "members",
offeringPrivacy: "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 saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Updates state
const myUpdates = ref([]);
const loadingUpdates = ref(false);
const loadingMoreUpdates = ref(false);
const hasMoreUpdates = ref(false);
const showDeleteUpdateModal = ref(false);
const updateToDelete = ref(null);
const deletingUpdate = ref(false);
// Available conversational support topics for peer support
const availableSupportTopics = [
"Co-founder relationships",
"Burnout prevention",
"Impostor syndrome",
"Work-life boundaries",
"Conflict resolution",
"General chat & support",
];
// Account management state
const contributionOptions = ref([]);
@ -944,18 +924,49 @@ const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
// Add skill
const addSkill = () => {
const skill = currentSkillInput.value.trim().replace(/,$/, "");
if (skill && !formData.skills.includes(skill)) {
formData.skills.push(skill);
currentSkillInput.value = "";
// Offering tag management
const addOfferingTag = () => {
const tag = currentOfferingTagInput.value.trim().replace(/,$/, "");
if (tag && !formData.offering.tags.includes(tag)) {
formData.offering.tags.push(tag);
currentOfferingTagInput.value = "";
}
};
// Remove skill
const removeSkill = (index) => {
formData.skills.splice(index, 1);
const removeOfferingTag = (index) => {
formData.offering.tags.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
@ -967,30 +978,56 @@ const loadProfile = () => {
formData.avatar = memberData.value.avatar || "";
formData.studio = memberData.value.studio || "";
formData.bio = memberData.value.bio || "";
formData.skills = memberData.value.skills || [];
formData.location = memberData.value.location || "";
formData.offering = memberData.value.offering || "";
formData.lookingFor = memberData.value.lookingFor || "";
formData.socialLinks = memberData.value.socialLinks || {
mastodon: "",
linkedin: "",
website: "",
other: "",
// Load offering (handle both old string and new object format)
if (typeof memberData.value.offering === "string") {
formData.offering = { text: memberData.value.offering, tags: [] };
} else {
formData.offering = {
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;
// 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)
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "public";
formData.avatarPrivacy = privacy.avatar || "members";
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.skillsPrivacy = privacy.skills || "members";
formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "members";
formData.socialLinksPrivacy = privacy.socialLinks || "members";
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
@ -1004,11 +1041,25 @@ const handleSubmit = async () => {
saveError.value = null;
try {
// Save profile data
await $fetch("/api/members/profile", {
method: "PATCH",
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;
// Refresh member data
@ -1035,78 +1086,6 @@ const resetForm = () => {
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
const formatDate = (dateString) => {
if (!dateString) return "";
@ -1334,7 +1313,6 @@ onMounted(async () => {
}
loadProfile();
loadUpdates();
loadContributionOptions();
selectedContributionTier.value = memberData.value?.contributionTier || "";
});
@ -1349,6 +1327,7 @@ useHead({
:deep(label) {
color: rgb(231 229 228) !important; /* stone-200 */
font-weight: 500;
text-align: left !important;
}
/* 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>
<!-- 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"
>Filter by skill:</span
>
<button
v-for="skill in availableSkills.slice(
v-for="skill in (availableSkills || []).slice(
0,
showAllSkills ? undefined : 10,
)"
@ -57,7 +57,7 @@
{{ 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"
@ -300,7 +300,7 @@ const { isAuthenticated } = useAuth();
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const loading = ref(false);
const loading = ref(true); // Start with loading true
const searchQuery = ref("");
const selectedCircle = ref("");
const selectedSkills = ref([]);
@ -339,11 +339,14 @@ const loadMembers = async () => {
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 || [];
} catch (error) {
console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
} finally {
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,
studio: member.studio,
bio: member.bio,
skills: member.skills,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,

View file

@ -24,7 +24,7 @@ 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(",") : [];
// Build query
const dbQuery = {
@ -45,15 +45,36 @@ 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;
}
}
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 createdAt",
)
.sort({ createdAt: -1 })
.lean();
@ -83,7 +104,6 @@ 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;
@ -92,17 +112,20 @@ export default defineEventHandler(async (event) => {
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();
return {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allSkills,
availableTags: allTags,
},
};
} catch (error) {

View file

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

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,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
privacyFields.forEach((privacyField) => {
if (body[privacyField] !== undefined) {
@ -100,7 +106,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

@ -51,7 +51,6 @@ const memberSchema = new mongoose.Schema({
avatar: String,
studio: String,
bio: String,
skills: [String],
location: String,
socialLinks: {
mastodon: String,
@ -59,14 +58,21 @@ 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 },
topics: [String],
skillTopics: [String], // Auto-populated from offering.tags, editable
supportTopics: [String], // Curated conversational/emotional support topics
availability: String,
personalMessage: String,
slackUsername: String,
@ -100,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"],