ghostguild-org/app/pages/member/profile.vue
Jennie Robinson Faber 2aa29ba64b feat: restructure profile page for community connections
Replace Skills Exchange section with CraftTagSelector in About You.
Replace Peer Support section with Community Connections using
CooperativeTagSelector. Update form data, load/save logic, and
notifications to use new field names with backward-compatible
fallbacks to old peerSupport data.
2026-04-05 16:28:34 +01:00

807 lines
22 KiB
Vue

<template>
<div class="profile-page">
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<p style="color: var(--text-faint)">Loading your profile...</p>
</div>
<!-- Unauthenticated State -->
<div v-else-if="!memberData" class="loading-state">
<p style="color: var(--text-faint); margin-bottom: 12px">
Please sign in to access your profile settings.
</p>
<button
class="btn btn-primary"
@click="
openLoginModal({
title: 'Sign in to your profile',
description: 'Enter your email to manage your profile settings',
})
"
>
Sign In
</button>
</div>
<div v-else class="profile-authenticated">
<!-- PAGE HEADER -->
<PageHeader
title="Edit Profile"
subtitle="How you appear to other members"
>
<NuxtLink
v-if="
(memberData?._id || memberData?.id) &&
memberData?.status === 'active' &&
formData.showInDirectory
"
:to="`/members/${memberData?._id || memberData?.id}`"
class="view-profile-link"
>
View my public profile &rarr;
</NuxtLink>
</PageHeader>
<!-- TWO-COLUMN FORM -->
<form class="page-content" @submit.prevent="handleSubmit">
<div class="profile-main">
<div class="profile-columns">
<!-- ======== LEFT COLUMN ======== -->
<div class="profile-col-left">
<div class="profile-col-inset">
<div class="section-label">Basics</div>
<div class="field">
<label>Name</label>
<input
v-model="formData.name"
type="text"
placeholder="Your name"
required
/>
</div>
<div class="row-2">
<div class="field">
<label>Pronouns</label>
<input
v-model="formData.pronouns"
type="text"
placeholder="e.g., she/her, they/them"
/>
<PrivacyToggle v-model="formData.pronounsPrivacy" />
</div>
<div class="field">
<label>Timezone</label>
<input
v-model="formData.timeZone"
type="text"
placeholder="e.g., America/Toronto"
/>
<PrivacyToggle v-model="formData.timeZonePrivacy" />
</div>
</div>
<div class="field">
<label>Avatar</label>
<div class="avatar-row">
<button
v-for="ghost in availableGhosts"
:key="ghost.value"
type="button"
class="avatar-option"
:class="{ selected: formData.avatar === ghost.value }"
:title="ghost.label"
@click="formData.avatar = ghost.value"
>
<img :src="ghost.image" :alt="ghost.label" />
</button>
</div>
<PrivacyToggle v-model="formData.avatarPrivacy" />
</div>
</div>
<!-- About You -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">About You</div>
<div class="row-2">
<div class="field">
<label>Studio / Organization</label>
<input
v-model="formData.studio"
type="text"
placeholder="Studio name"
/>
<PrivacyToggle v-model="formData.studioPrivacy" />
</div>
<div class="field">
<label>Location</label>
<input
v-model="formData.location"
type="text"
placeholder="Toronto, ON"
/>
<PrivacyToggle v-model="formData.locationPrivacy" />
</div>
</div>
<div class="field">
<label>Bio</label>
<textarea
v-model="formData.bio"
rows="4"
placeholder="Share your background, interests, and experience..."
maxlength="300"
></textarea>
<div class="char-count">
{{ formData.bio?.length || 0 }} / 300
</div>
<PrivacyToggle v-model="formData.bioPrivacy" />
</div>
<!-- What I Do (craft tags) -->
<div class="field">
<label>What I Do</label>
<CraftTagSelector
v-model="formData.craftTags"
:tags="craftTags"
@suggest="tagSuggestPool = 'craft'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
</div>
</div>
<!-- Visibility -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Visibility</div>
<div class="toggle-field">
<USwitch v-model="formData.showInDirectory" />
<div class="toggle-label">
Show in Member Directory
<span class="toggle-sub"
>Your profile will appear in the public member
listing</span
>
</div>
</div>
</div>
</div>
<!-- ======== RIGHT COLUMN ======== -->
<div class="profile-col-right">
<div class="profile-col-inset">
<div class="section-label">Community Connections</div>
<div class="field">
<label>Topics</label>
<CooperativeTagSelector
v-model="formData.communityConnectionsTopics"
:tags="cooperativeTags"
@suggest="tagSuggestPool = 'cooperative'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.communityConnectionsPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.communityConnectionsDetails"
rows="3"
placeholder="What are you hoping to connect about?"
maxlength="300"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsDetails?.length || 0 }} / 300
</div>
</div>
<div class="toggle-field">
<USwitch v-model="formData.communityConnectionsOfferPeerSupport" />
<div class="toggle-label">
Offer Peer Support
<span class="toggle-sub"
>Let other members request 1:1 time with you</span
>
</div>
</div>
<div v-if="formData.communityConnectionsOfferPeerSupport" class="connections-panel">
<div class="field">
<label>Availability</label>
<textarea
v-model="formData.communityConnectionsAvailability"
rows="3"
placeholder="e.g. Weekday afternoons ET"
></textarea>
</div>
<div class="field">
<label>Slack Handle</label>
<input
v-model="formData.communityConnectionsSlackHandle"
type="text"
placeholder="@yourslackname"
/>
</div>
<div class="field">
<label>Personal Message</label>
<textarea
v-model="formData.communityConnectionsPersonalMessage"
rows="3"
maxlength="200"
placeholder="Brief note shown to people requesting time with you"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
</div>
</div>
</div>
</div>
<!-- Notifications -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Notifications</div>
<div class="toggle-field">
<USwitch v-model="formData.notifyEvents" />
<div class="toggle-label">
Event reminders
<span class="toggle-sub"
>Get notified about upcoming events</span
>
</div>
</div>
<div class="toggle-field">
<USwitch v-model="formData.notifyUpdates" />
<div class="toggle-label">
Community updates
<span class="toggle-sub"
>New posts from members you follow</span
>
</div>
</div>
<div class="toggle-field">
<USwitch v-model="formData.notifyConnectionRequests" />
<div class="toggle-label">
Connection requests
<span class="toggle-sub"
>When someone wants to connect</span
>
</div>
</div>
</div>
</div>
</div>
<!-- ======== SAVE BAR ======== -->
<div class="save-bar">
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !hasChanges"
>
{{ saving ? "Saving..." : "Save Profile" }}
</button>
<button type="button" class="btn" @click="resetForm">
Reset Changes
</button>
<span v-if="saveSuccess" class="save-msg save-msg-ok"
>Profile updated.</span
>
<span v-if="saveError" class="save-msg save-msg-err">{{
saveError
}}</span>
</div>
</div>
</form>
</div>
<!-- Tag Suggest Modal -->
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
// Available ghost avatars
const availableGhosts = [
{
value: "disbelieving",
label: "Disbelieving",
image: "/ghosties/Ghost-Disbelieving.png",
},
{
value: "double-take",
label: "Double Take",
image: "/ghosties/Ghost-Double-Take.png",
},
{
value: "exasperated",
label: "Exasperated",
image: "/ghosties/Ghost-Exasperated.png",
},
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
];
// Fetch tags and split into pools
const { data: tagsData } = await useFetch("/api/tags");
const craftTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
);
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
);
// Tag suggest modal state
const showTagSuggestModal = ref(false);
const tagSuggestPool = ref("");
// Form state
const formData = reactive({
name: "",
pronouns: "",
timeZone: "",
avatar: "",
studio: "",
bio: "",
location: "",
showInDirectory: true,
// Craft tags
craftTags: [],
craftTagsPrivacy: "members",
// Community connections
communityConnectionsTopics: [],
communityConnectionsPrivacy: "members",
communityConnectionsDetails: "",
communityConnectionsOfferPeerSupport: false,
communityConnectionsAvailability: "",
communityConnectionsSlackHandle: "",
communityConnectionsPersonalMessage: "",
// Privacy
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "members",
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
// Notifications
notifyEvents: true,
notifyUpdates: true,
notifyPeerRequests: true,
notifyConnectionRequests: true,
});
const loading = ref(false);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Computed
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
// Load member data into form
const loadProfile = () => {
if (memberData.value) {
formData.name = memberData.value.name || "";
formData.pronouns = memberData.value.pronouns || "";
formData.timeZone = memberData.value.timeZone || "";
formData.avatar = memberData.value.avatar || "";
formData.studio = memberData.value.studio || "";
formData.bio = memberData.value.bio || "";
formData.location = memberData.value.location || "";
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load craft tags
formData.craftTags = Array.isArray(memberData.value.craftTags)
? [...memberData.value.craftTags]
: [];
// Load community connections (with fallback to old peerSupport fields)
const cc = memberData.value.communityConnections || {};
formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : [];
formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false;
formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || "";
formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || "";
formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || "";
formData.communityConnectionsDetails = cc.details || "";
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "members";
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.craftTagsPrivacy = privacy.craftTags || "members";
formData.communityConnectionsPrivacy = privacy.communityConnections || "members";
// Load notification prefs
const notifs = memberData.value.notifications || {};
formData.notifyEvents = notifs.events ?? true;
formData.notifyUpdates = notifs.updates ?? true;
formData.notifyPeerRequests = notifs.peerRequests ?? true;
formData.notifyConnectionRequests = notifs.connectionRequests ?? true;
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
}
};
// Handle form submission
const handleSubmit = async () => {
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
// Save profile data (includes craft tags + privacy + notifications)
await $fetch("/api/members/profile", {
method: "PATCH",
body: {
...formData,
craftTags: formData.craftTags,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
connectionRequests: formData.notifyConnectionRequests,
},
},
});
// Save community connections data
await $fetch("/api/members/me/community-connections", {
method: "PATCH",
body: {
topics: formData.communityConnectionsTopics,
offerPeerSupport: formData.communityConnectionsOfferPeerSupport,
availability: formData.communityConnectionsAvailability,
slackHandle: formData.communityConnectionsSlackHandle,
personalMessage: formData.communityConnectionsPersonalMessage,
details: formData.communityConnectionsDetails,
},
});
saveSuccess.value = true;
// Refresh member data
await checkMemberStatus();
loadProfile();
setTimeout(() => {
saveSuccess.value = false;
}, 3000);
} catch (error) {
console.error("Profile save error:", error);
saveError.value =
error.data?.message || "Failed to save profile. Please try again.";
} finally {
saving.value = false;
}
};
// Reset form to initial state
const resetForm = () => {
loadProfile();
saveSuccess.value = false;
saveError.value = null;
};
// Initialize on mount
onMounted(async () => {
if (!memberData.value) {
loading.value = true;
const isAuthenticated = await checkMemberStatus();
loading.value = false;
if (!isAuthenticated) {
openLoginModal({
title: "Sign in to your profile",
description: "Enter your email to manage your profile settings",
});
return;
}
}
loadProfile();
});
useHead({
title: "Edit Profile - Ghost Guild",
});
</script>
<style scoped>
.profile-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.profile-authenticated {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- LOADING / EMPTY STATE ---- */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.profile-page > .loading-state {
flex: 1;
}
/* ---- CONTENT AREA ---- */
.page-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
.profile-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.profile-columns {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: 0;
align-items: stretch;
min-height: 0;
}
.profile-col-left,
.profile-col-right {
display: flex;
flex-direction: column;
min-height: 0;
align-self: stretch;
}
@media (min-width: 1025px) {
.profile-col-left {
border-right: 1px dashed var(--border);
}
}
.profile-col-left > .profile-col-inset:first-of-type,
.profile-col-right > .profile-col-inset:first-of-type {
padding-top: 14px;
}
.profile-col-left .profile-col-inset {
padding-left: 28px;
padding-right: 24px;
}
.profile-col-right .profile-col-inset {
padding-left: 24px;
padding-right: 28px;
}
/* ---- MULTI-COLUMN ROWS ---- */
.row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* ---- PRIVACY TOGGLE SPACING ---- */
.field :deep(.priv) {
margin-top: 4px;
}
/* ---- FIELD LABELS (distinct from .section-label) ---- */
.field label {
font-size: 11px;
text-transform: none;
letter-spacing: normal;
color: var(--text-dim);
}
/* ---- SOLID INPUT BORDERS ---- */
.field input,
.field select,
.field textarea {
border-style: solid;
}
.field :deep(.tags) {
border-style: solid;
}
/* ---- VIEW PROFILE LINK ---- */
.view-profile-link {
display: inline-block;
margin-top: 8px;
font-size: 12px;
color: var(--candle);
text-decoration: none;
}
.view-profile-link:hover {
text-decoration: underline;
}
/* ---- AVATAR PICKER ---- */
.avatar-row {
display: flex;
gap: 6px;
align-items: center;
}
.avatar-option {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
background: var(--bg);
cursor: pointer;
padding: 3px;
transition: all 0.12s;
}
.avatar-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.avatar-option:hover {
border-color: var(--candle-dim);
background: var(--surface-hover);
}
.avatar-option.selected {
border: 2px solid var(--candle);
background: var(--surface);
}
/* ---- TEXTAREA RESIZE ---- */
.field textarea {
resize: vertical;
}
/* ---- CHAR COUNT ---- */
.char-count {
font-size: 10px;
color: var(--text-faint);
text-align: right;
margin-top: 2px;
}
/* ---- TOGGLE FIELD ---- */
.toggle-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
}
.toggle-label {
font-size: 12px;
color: var(--text);
line-height: 1.4;
}
.toggle-sub {
display: block;
font-size: 11px;
color: var(--text-faint);
margin-top: 1px;
}
/* ---- CONNECTIONS PANEL ---- */
.connections-panel {
border: 1px dashed var(--border);
padding: 12px 14px;
margin-top: 4px;
margin-bottom: 12px;
background: var(--surface);
}
/* ---- DISABLED BUTTON ---- */
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---- SAVE BAR ---- */
.save-bar {
flex-shrink: 0;
padding: 24px 28px 24px;
margin-top: 0;
border-top: 1px dashed var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.save-msg {
font-size: 11px;
margin-left: auto;
}
.save-msg-ok {
color: var(--green, var(--candle));
}
.save-msg-err {
color: var(--ember);
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.profile-columns {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.profile-col-left {
border-right: none;
border-bottom: 1px dashed var(--border);
padding-bottom: 20px;
margin-bottom: 20px;
}
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 28px;
padding-right: 28px;
}
}
@media (max-width: 768px) {
.row-2 {
grid-template-columns: 1fr;
}
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 16px;
padding-right: 16px;
}
.save-bar {
padding-left: 16px;
padding-right: 16px;
flex-wrap: wrap;
}
}
</style>