ghostguild-org/app/pages/member/profile.vue

854 lines
24 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"
/>
<!-- 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">
<div class="label-row">
<label>Pronouns</label>
<PrivacyToggle v-model="formData.pronounsPrivacy" />
</div>
<input v-model="formData.pronouns" type="text" placeholder="e.g., she/her, they/them" />
</div>
<div class="field">
<div class="label-row">
<label>Timezone</label>
<PrivacyToggle v-model="formData.timeZonePrivacy" />
</div>
<input v-model="formData.timeZone" type="text" placeholder="e.g., America/Toronto" />
</div>
</div>
<div class="field">
<div class="label-row">
<label>Avatar</label>
<PrivacyToggle v-model="formData.avatarPrivacy" />
</div>
<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>
</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">
<div class="label-row">
<label>Studio / Organization</label>
<PrivacyToggle v-model="formData.studioPrivacy" />
</div>
<input v-model="formData.studio" type="text" placeholder="Studio name" />
</div>
<div class="field">
<div class="label-row">
<label>Location</label>
<PrivacyToggle v-model="formData.locationPrivacy" />
</div>
<input v-model="formData.location" type="text" placeholder="Toronto, ON" />
</div>
</div>
<div class="field">
<div class="label-row">
<label>Bio</label>
<PrivacyToggle v-model="formData.bioPrivacy" />
</div>
<textarea v-model="formData.bio" rows="2" placeholder="Share your background, interests, and experience..." maxlength="300"></textarea>
<div class="char-count">{{ formData.bio?.length || 0 }} / 300</div>
</div>
</div>
<!-- Skills Exchange -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Skills Exchange</div>
<div class="field">
<div class="label-row">
<label>What I Can Contribute</label>
<PrivacyToggle v-model="formData.offeringPrivacy" />
</div>
<TagInput v-model="formData.offering.tags" placeholder="add skill..." />
</div>
<div class="field">
<label>Details</label>
<textarea v-model="formData.offering.text" rows="2" placeholder="e.g., I have 10+ years in Unity and love helping new devs."></textarea>
</div>
<div class="field">
<div class="label-row">
<label>What I'm Looking For</label>
<PrivacyToggle v-model="formData.lookingForPrivacy" />
</div>
<TagInput v-model="formData.lookingFor.tags" placeholder="add topic..." />
</div>
<div class="field">
<label>Details</label>
<textarea v-model="formData.lookingFor.text" rows="2" placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."></textarea>
</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">Peer Support</div>
<div class="toggle-field">
<USwitch v-model="formData.peerSupportEnabled" />
<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.peerSupportEnabled" class="peer-panel">
<div class="field">
<label>Skill-Based Topics</label>
<TagInput v-model="formData.peerSupportSkillTopics" placeholder="add topic..." />
<div v-if="suggestedSkillTopics.length" class="suggested">
Suggested from your offerings:
<a
v-for="tag in suggestedSkillTopics"
:key="tag"
@click="addSuggestedSkillTopic(tag)"
>{{ tag }}</a>
</div>
</div>
<div class="field">
<label>Conversational Topics</label>
<div class="checkbox-grid">
<label
v-for="topic in availableSupportTopics"
:key="topic"
class="checkbox-item"
:class="{ checked: formData.peerSupportSupportTopics.includes(topic) }"
@click.prevent="toggleSupportTopic(topic)"
>
<span class="cb">&#10003;</span>
{{ topic }}
</label>
</div>
</div>
<div class="field">
<label>Availability</label>
<textarea v-model="formData.peerSupportAvailability" rows="2" placeholder="e.g. Weekday afternoons ET"></textarea>
</div>
<div class="field">
<label>Slack Handle</label>
<input v-model="formData.peerSupportSlackUsername" type="text" placeholder="@yourslackname" />
</div>
<div class="field">
<label>Personal Message</label>
<textarea v-model="formData.peerSupportMessage" rows="2" maxlength="200" placeholder="Brief note shown to people requesting time with you"></textarea>
<div class="char-count">{{ formData.peerSupportMessage?.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.notifyPeerRequests" />
<div class="toggle-label">
Peer support 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>
</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' },
]
// Form state
const formData = reactive({
name: '',
pronouns: '',
timeZone: '',
avatar: '',
studio: '',
bio: '',
location: '',
offering: { text: '', tags: [] },
lookingFor: { text: '', tags: [] },
showInDirectory: true,
// Peer support
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: '',
peerSupportMessage: '',
peerSupportSlackUsername: '',
// Privacy
pronounsPrivacy: 'members',
timeZonePrivacy: 'members',
avatarPrivacy: 'members',
studioPrivacy: 'members',
bioPrivacy: 'members',
locationPrivacy: 'members',
offeringPrivacy: 'members',
lookingForPrivacy: 'members',
// Notifications
notifyEvents: true,
notifyUpdates: true,
notifyPeerRequests: true,
})
const loading = ref(false)
const saving = ref(false)
const saveSuccess = ref(false)
const saveError = ref(null)
const initialData = ref(null)
// Available conversational support topics
const availableSupportTopics = [
'Co-founder relationships',
'Burnout prevention',
'Impostor syndrome',
'Work-life boundaries',
'Conflict resolution',
'General chat & support',
]
// Computed
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value)
})
const suggestedSkillTopics = computed(() => {
if (!formData.offering.tags?.length) return []
return formData.offering.tags.filter(
(t) => !formData.peerSupportSkillTopics?.includes(t)
)
})
// Toggle a support topic in/out of the selection
const toggleSupportTopic = (topic) => {
const idx = formData.peerSupportSupportTopics.indexOf(topic)
if (idx >= 0) {
formData.peerSupportSupportTopics.splice(idx, 1)
} else {
formData.peerSupportSupportTopics.push(topic)
}
}
const addSuggestedSkillTopic = (tag) => {
if (!Array.isArray(formData.peerSupportSkillTopics)) {
formData.peerSupportSkillTopics = []
}
if (!formData.peerSupportSkillTopics.includes(tag)) {
formData.peerSupportSkillTopics.push(tag)
}
}
// 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 || ''
// Load offering (handle both old string and new object format)
if (typeof memberData.value.offering === 'string') {
formData.offering.text = memberData.value.offering
formData.offering.tags = []
} else if (memberData.value.offering) {
formData.offering.text = memberData.value.offering?.text || ''
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
? [...memberData.value.offering.tags]
: []
} else {
formData.offering.text = ''
formData.offering.tags = []
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === 'string') {
formData.lookingFor.text = memberData.value.lookingFor
formData.lookingFor.tags = []
} else if (memberData.value.lookingFor) {
formData.lookingFor.text = memberData.value.lookingFor?.text || ''
formData.lookingFor.tags = Array.isArray(memberData.value.lookingFor?.tags)
? [...memberData.value.lookingFor.tags]
: []
} else {
formData.lookingFor.text = ''
formData.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 = Array.isArray(memberData.value.peerSupport.skillTopics)
? [...memberData.value.peerSupport.skillTopics]
: []
formData.peerSupportSupportTopics = Array.isArray(memberData.value.peerSupport.supportTopics)
? [...memberData.value.peerSupport.supportTopics]
: []
formData.peerSupportAvailability = memberData.value.peerSupport.availability || ''
formData.peerSupportMessage = memberData.value.peerSupport.personalMessage || ''
formData.peerSupportSlackUsername = memberData.value.peerSupport.slackUsername || ''
} else {
formData.peerSupportEnabled = false
formData.peerSupportSkillTopics = []
formData.peerSupportSupportTopics = []
formData.peerSupportAvailability = ''
formData.peerSupportMessage = ''
formData.peerSupportSlackUsername = ''
}
// 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.offeringPrivacy = privacy.offering || 'members'
formData.lookingForPrivacy = privacy.lookingFor || 'members'
// Load notification prefs
const notifs = memberData.value.notifications || {}
formData.notifyEvents = notifs.events ?? true
formData.notifyUpdates = notifs.updates ?? true
formData.notifyPeerRequests = notifs.peerRequests ?? 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
await $fetch('/api/members/profile', {
method: 'PATCH',
body: {
...formData,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
},
},
})
// 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
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;
}
/* Grid + save bar: one flex child so the center rule can span both */
.profile-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
/* Full-height vertical rule between columns (through save bar); 1fr | 1fr grid */
.profile-main::before {
display: none;
}
@media (min-width: 1025px) {
.profile-main::before {
display: block;
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 0;
border-left: 1px dashed var(--border);
pointer-events: none;
}
}
/* ---- 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;
}
.profile-col-left {
border-right: none;
}
.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;
}
/* ---- LABEL WITH PRIVACY ---- */
.label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.label-row label {
margin-bottom: 0;
}
/* ---- 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;
}
/* ---- CHECKBOX GRID ---- */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 12px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
padding: 2px 0;
user-select: none;
}
.checkbox-item .cb {
width: 13px;
height: 13px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: transparent;
flex-shrink: 0;
}
.checkbox-item.checked .cb {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.checkbox-item:hover {
color: var(--text);
}
/* ---- PEER SUPPORT PANEL ---- */
.peer-panel {
border: 1px dashed var(--border);
padding: 12px 14px;
margin-top: 4px;
margin-bottom: 12px;
background: var(--surface);
}
.peer-panel .suggested {
font-size: 10px;
color: var(--text-faint);
margin-top: 4px;
}
.peer-panel .suggested a {
color: var(--candle);
text-decoration: underline;
cursor: pointer;
margin-left: 4px;
}
/* ---- 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;
}
.checkbox-grid {
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>