Simplify the feature to pure discovery (filter by topic, see matching members, copy Slack handle). Drop the connection request/confirm flow entirely — Connection model, 7 API endpoints, useConnections composable, and TagInput component deleted. - Rename communityConnections → communityEcology in schema, API, pages - Delete legacy fields: offering, lookingFor, peerSupport - New /ecology page, /api/ecology/suggestions, community-ecology.patch - Nav: "Connections" → "Ecology", remove pending-count badge - Fix auth/member.get.js missing craftTags + communityEcology - Add community_ecology_updated activity log type - Expose slackHandle conditionally when offerPeerSupport is true - Add migration script at scripts/migrate-to-ecology.js (run before deploy)
666 lines
18 KiB
Vue
666 lines
18 KiB
Vue
<template>
|
|
<PageShell as="form" @submit.prevent="handleSubmit">
|
|
<ClientOnly>
|
|
<div v-if="loading" class="loading-state">
|
|
<p style="color: var(--text-faint)">Loading your profile...</p>
|
|
</div>
|
|
|
|
<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
|
|
type="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>
|
|
|
|
<template v-else>
|
|
<!-- PAGE HEADER -->
|
|
<PageHeader
|
|
title="Edit Profile"
|
|
subtitle="How you appear to other members"
|
|
>
|
|
<NuxtLink
|
|
v-if="memberId && memberData?.status === MEMBER_STATUSES.ACTIVE && formData.showInDirectory"
|
|
:to="`/members/${memberId}`"
|
|
class="view-profile-link"
|
|
>
|
|
View my public profile →
|
|
</NuxtLink>
|
|
</PageHeader>
|
|
|
|
<ColumnsLayout cols="2">
|
|
<template #left>
|
|
<PageSection>
|
|
<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>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top">
|
|
<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>
|
|
|
|
<div class="field">
|
|
<label>What I Do</label>
|
|
<CraftTagSelector
|
|
v-model="formData.craftTags"
|
|
:tags="craftTags"
|
|
@suggest="openTagSuggest('craft')"
|
|
/>
|
|
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
|
</div>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top">
|
|
<div class="section-label">Visibility</div>
|
|
|
|
<div class="toggle-field">
|
|
<USwitch
|
|
v-model="formData.showInDirectory"
|
|
aria-label="Show in Member Directory"
|
|
/>
|
|
<div class="toggle-label">
|
|
Show in Member Directory
|
|
<span class="toggle-sub"
|
|
>Your profile will appear in the public member listing</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
</template>
|
|
|
|
<template #right>
|
|
<PageSection>
|
|
<div class="section-label">Community Ecology</div>
|
|
|
|
<div class="field">
|
|
<label>Topics</label>
|
|
<CooperativeTagSelector
|
|
v-model="formData.communityEcologyTopics"
|
|
:tags="cooperativeTags"
|
|
@suggest="openTagSuggest('cooperative')"
|
|
/>
|
|
<PrivacyToggle v-model="formData.communityEcologyPrivacy" />
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Details</label>
|
|
<textarea
|
|
v-model="formData.communityEcologyDetails"
|
|
rows="3"
|
|
placeholder="What are you hoping to connect about?"
|
|
maxlength="300"
|
|
></textarea>
|
|
<div class="char-count">
|
|
{{ formData.communityEcologyDetails?.length || 0 }} / 300
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toggle-field">
|
|
<USwitch
|
|
v-model="formData.communityEcologyOfferPeerSupport"
|
|
aria-label="Offer Peer Support"
|
|
/>
|
|
<div class="toggle-label">
|
|
Offer Peer Support
|
|
<span class="toggle-sub"
|
|
>Share your Slack handle so other members can reach out</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="formData.communityEcologyOfferPeerSupport" class="connections-panel">
|
|
<div class="field">
|
|
<label>Availability</label>
|
|
<textarea
|
|
v-model="formData.communityEcologyAvailability"
|
|
rows="3"
|
|
placeholder="e.g. Weekday afternoons ET"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Slack Handle</label>
|
|
<input
|
|
v-model="formData.communityEcologySlackHandle"
|
|
type="text"
|
|
placeholder="@yourslackname"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Personal Message</label>
|
|
<textarea
|
|
v-model="formData.communityEcologyPersonalMessage"
|
|
rows="3"
|
|
maxlength="200"
|
|
placeholder="Brief note shown alongside your Slack handle"
|
|
></textarea>
|
|
<div class="char-count">
|
|
{{ formData.communityEcologyPersonalMessage?.length || 0 }} / 200
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top">
|
|
<div class="section-label">Notifications</div>
|
|
|
|
<div
|
|
v-for="toggle in notificationToggles"
|
|
:key="toggle.key"
|
|
class="toggle-field"
|
|
>
|
|
<USwitch
|
|
v-model="formData.notifications[toggle.key]"
|
|
:aria-label="toggle.label"
|
|
/>
|
|
<div class="toggle-label">
|
|
{{ toggle.label }}
|
|
<span class="toggle-sub">{{ toggle.sub }}</span>
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
</template>
|
|
</ColumnsLayout>
|
|
|
|
<!-- ======== 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>
|
|
</template>
|
|
|
|
<template #fallback>
|
|
<div class="loading-state">
|
|
<p style="color: var(--text-faint)">Loading your profile...</p>
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
|
|
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
|
</PageShell>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
|
|
|
definePageMeta({
|
|
middleware: 'auth',
|
|
});
|
|
|
|
const { memberData, checkMemberStatus } = useAuth();
|
|
const { openLoginModal } = useLoginModal();
|
|
|
|
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" },
|
|
];
|
|
|
|
const notificationToggles = [
|
|
{ key: "events", label: "Event reminders", sub: "Get notified about upcoming events" },
|
|
{ key: "updates", label: "Community updates", sub: "New posts from members you follow" },
|
|
];
|
|
|
|
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"),
|
|
);
|
|
|
|
const showTagSuggestModal = ref(false);
|
|
const tagSuggestPool = ref("");
|
|
|
|
const openTagSuggest = (pool) => {
|
|
tagSuggestPool.value = pool;
|
|
showTagSuggestModal.value = true;
|
|
};
|
|
|
|
const formData = reactive({
|
|
name: "",
|
|
pronouns: "",
|
|
timeZone: "",
|
|
avatar: "",
|
|
studio: "",
|
|
bio: "",
|
|
location: "",
|
|
showInDirectory: true,
|
|
craftTags: [],
|
|
craftTagsPrivacy: "members",
|
|
communityEcologyTopics: [],
|
|
communityEcologyPrivacy: "members",
|
|
communityEcologyDetails: "",
|
|
communityEcologyOfferPeerSupport: false,
|
|
communityEcologyAvailability: "",
|
|
communityEcologySlackHandle: "",
|
|
communityEcologyPersonalMessage: "",
|
|
pronounsPrivacy: "members",
|
|
timeZonePrivacy: "members",
|
|
avatarPrivacy: "members",
|
|
studioPrivacy: "members",
|
|
bioPrivacy: "members",
|
|
locationPrivacy: "members",
|
|
notifications: {
|
|
events: true,
|
|
updates: true,
|
|
},
|
|
});
|
|
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const saveSuccess = ref(false);
|
|
const saveError = ref(null);
|
|
const initialData = ref(null);
|
|
let saveSuccessTimer = null;
|
|
|
|
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
|
|
|
|
const hasChanges = computed(() => {
|
|
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
|
|
});
|
|
|
|
const loadProfile = () => {
|
|
if (!memberData.value) return;
|
|
|
|
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;
|
|
|
|
formData.craftTags = Array.isArray(memberData.value.craftTags)
|
|
? [...memberData.value.craftTags]
|
|
: [];
|
|
|
|
const ecology = memberData.value.communityEcology || {};
|
|
formData.communityEcologyTopics = Array.isArray(ecology.topics) ? [...ecology.topics] : [];
|
|
formData.communityEcologyOfferPeerSupport = ecology.offerPeerSupport ?? false;
|
|
formData.communityEcologyAvailability = ecology.availability || "";
|
|
formData.communityEcologySlackHandle = ecology.slackHandle || "";
|
|
formData.communityEcologyPersonalMessage = ecology.personalMessage || "";
|
|
formData.communityEcologyDetails = ecology.details || "";
|
|
|
|
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.communityEcologyPrivacy = privacy.communityEcology || "members";
|
|
|
|
const notifs = memberData.value.notifications || {};
|
|
formData.notifications.events = notifs.events ?? true;
|
|
formData.notifications.updates = notifs.updates ?? true;
|
|
|
|
initialData.value = JSON.parse(JSON.stringify(formData));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
saving.value = true;
|
|
saveSuccess.value = false;
|
|
saveError.value = null;
|
|
|
|
try {
|
|
await Promise.all([
|
|
$fetch("/api/members/profile", {
|
|
method: "PATCH",
|
|
body: { ...formData },
|
|
}),
|
|
$fetch("/api/members/me/community-ecology", {
|
|
method: "PATCH",
|
|
body: {
|
|
topics: formData.communityEcologyTopics,
|
|
offerPeerSupport: formData.communityEcologyOfferPeerSupport,
|
|
availability: formData.communityEcologyAvailability,
|
|
slackHandle: formData.communityEcologySlackHandle,
|
|
personalMessage: formData.communityEcologyPersonalMessage,
|
|
details: formData.communityEcologyDetails,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
saveSuccess.value = true;
|
|
|
|
await checkMemberStatus();
|
|
loadProfile();
|
|
|
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
|
saveSuccessTimer = setTimeout(() => {
|
|
saveSuccess.value = false;
|
|
saveSuccessTimer = null;
|
|
}, 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;
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
loadProfile();
|
|
saveSuccess.value = false;
|
|
saveError.value = null;
|
|
};
|
|
|
|
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();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
|
});
|
|
|
|
useHead({
|
|
title: "Edit Profile - Ghost Guild",
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ---- LOADING / EMPTY STATE ---- */
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 80px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* ---- 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: 768px) {
|
|
.row-2 {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.save-bar {
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|