ghostguild-org/app/pages/member/profile.vue
Jennie Robinson Faber 0c489cf2c3 style: underline contributor links + timezone select placeholder color
- join.vue: underline links inside .checkbox-label
- profile.vue: underline .posts-empty-link by default; remove hover-only
  underline rule; tint timezone select placeholder via :deep slot
2026-04-26 17:55:54 +01:00

845 lines
21 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">
<NuxtLink
v-if="
memberId &&
memberData?.status === MEMBER_STATUSES.ACTIVE &&
formData.showInDirectory
"
:to="`/members/${memberId}`"
class="view-profile-link"
>
View my public profile &rarr;
</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="field">
<label>Pronouns</label>
<input
v-model="formData.pronouns"
type="text"
placeholder="e.g., she/her, they/them"
>
</div>
<div class="field">
<label>Timezone</label>
<USelectMenu
v-model="formData.timeZone"
:items="timezoneItems"
value-key="value"
searchable
searchable-placeholder="Search timezones..."
placeholder="Select a timezone"
class="timezone-select"
:ui="{
content: 'tz-content',
item: 'tz-item',
input: 'tz-input',
}"
/>
</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>
</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"
>
</div>
<div class="field">
<label>Location</label>
<input
v-model="formData.location"
type="text"
placeholder="Toronto, ON"
>
</div>
</div>
<div class="field">
<label>Bio</label>
<textarea
v-model="formData.bio"
rows="4"
placeholder="Share your background, interests, and experience..."
maxlength="300"
/>
<div class="char-count">
{{ formData.bio?.length || 0 }} / 300
</div>
</div>
<div class="field">
<label>What I Do</label>
<CraftTagSelector
v-model="formData.craftTags"
:tags="craftTags"
@suggest="openTagSuggest('craft')"
/>
</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 private member
directory.</span
>
</div>
</div>
</PageSection>
</template>
<template #right>
<PageSection>
<div class="section-label">Board</div>
<div class="field">
<label>Gamma Space Slack Handle</label>
<input
v-model="formData.boardSlackHandle"
type="text"
placeholder="@yourslackname"
>
<div class="field-help">
Shown on your board posts so other members can reach out.
</div>
</div>
<div class="posts-header">
<div class="posts-heading">Your Posts</div>
<NuxtLink to="/board" class="posts-new-link"
>+ New Post</NuxtLink
>
</div>
<div v-if="myPosts.length === 0" class="posts-empty">
No posts yet.
<NuxtLink to="/board" class="posts-empty-link">
Visit the Board
</NuxtLink>
to share what you're seeking or offering.
</div>
<ul v-else class="posts-list">
<li v-for="post in myPosts" :key="post._id" class="post-item">
<div class="post-body">
<div class="post-title">{{ post.title }}</div>
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
</div>
<div class="post-actions">
<NuxtLink to="/board" class="post-action">Edit</NuxtLink>
<button
type="button"
class="post-action post-action-danger"
@click="handleDeletePost(post)"
>
Delete
</button>
</div>
</li>
</ul>
</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>
<PageSection divider="top">
<div class="section-label">Recent Activity</div>
<div v-if="activityLoading" class="activity-empty">
Loading activity…
</div>
<ul v-else-if="recentActivity.length" class="activity-list">
<li
v-for="entry in recentActivity"
:key="entry._id"
class="activity-item"
>
<div class="activity-time">
{{ formatActivityTime(entry.timestamp) }}
</div>
<div class="activity-text">
<template v-if="formatActivity(entry).link">
<span>{{
formatActivity(entry).text.split(
formatActivity(entry).linkText,
)[0]
}}</span>
<NuxtLink
:to="formatActivity(entry).link"
class="activity-link"
>
{{ formatActivity(entry).linkText }}
</NuxtLink>
</template>
<span v-else>{{ formatActivity(entry).text }}</span>
</div>
</li>
</ul>
<div v-else class="activity-empty">
Your activity will appear here as you use the Guild.
</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>
</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";
import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { formatActivity } from "~/utils/activityText";
definePageMeta({
middleware: "auth",
});
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const { posts: myPosts, fetchPosts, deletePost } = useBoardPosts();
const toast = useToast();
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" },
];
// Compute current UTC offset for an IANA timezone (DST-aware).
const utcOffset = (tz) => {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts(new Date());
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
// "GMT-05:00" → "UTC-05:00"; "GMT" → "UTC+00:00"
if (name === "GMT") return "UTC+00:00";
return name.replace("GMT", "UTC");
} catch {
return "";
}
};
// Include the saved timezone as a custom option if it's not in the curated list.
const timezoneItems = computed(() => {
const saved = formData.timeZone;
const list = TIMEZONE_OPTIONS.map((t) => {
const off = utcOffset(t.value);
return { ...t, label: off ? `${t.label} (${off})` : t.label };
});
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
const off = utcOffset(saved);
list.unshift({ label: off ? `${saved} (${off})` : saved, value: saved });
}
return list;
});
const notificationToggles = [
{
key: "events",
label: "Registration & cancellation emails",
sub: "Confirmation when you register for an event, and notice if it's cancelled",
},
];
const { data: tagsData } = await useFetch("/api/tags");
const craftTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
);
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: [],
boardSlackHandle: "",
notifications: {
events: true,
},
});
const loading = ref(false);
const saving = ref(false);
const initialData = ref(null);
const recentActivity = ref([]);
const activityLoading = ref(false);
const formatActivityTime = (date) => {
const now = new Date();
const d = new Date(date);
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
const loadRecentActivity = async () => {
activityLoading.value = true;
try {
const data = await $fetch("/api/members/me/activity", {
params: { limit: 5 },
});
recentActivity.value = data.entries || [];
} catch (err) {
console.error("Failed to load activity:", err);
recentActivity.value = [];
} finally {
activityLoading.value = false;
}
};
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 board = memberData.value.board || {};
formData.boardSlackHandle = board.slackHandle || "";
const notifs = memberData.value.notifications || {};
formData.notifications.events = notifs.events ?? true;
initialData.value = JSON.parse(JSON.stringify(formData));
};
const handleSubmit = async () => {
saving.value = true;
try {
await $fetch("/api/members/profile", {
method: "PATCH",
body: { ...formData },
});
await checkMemberStatus();
loadProfile();
toast.add({ title: "Profile updated", color: "success" });
} catch (error) {
console.error("Profile save error:", error);
toast.add({
title: "Update failed",
description:
error.data?.statusMessage || error.data?.message || "Please try again.",
color: "error",
});
} finally {
saving.value = false;
}
};
const resetForm = () => {
loadProfile();
};
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();
if (memberId.value) {
await Promise.allSettled([
fetchPosts({ author: memberId.value }),
loadRecentActivity(),
]);
}
});
const postExcerpt = (post) => {
const text = post.seeking || post.offering || "";
if (text.length <= 80) return text;
return text.slice(0, 80).trimEnd() + "...";
};
const handleDeletePost = async (post) => {
if (!window.confirm(`Delete "${post.title}"?`)) return;
try {
await deletePost(post._id, { author: memberId.value });
} catch (error) {
console.error("Delete post error:", error);
toast.add({
title: "Failed to delete post",
description: error.data?.message || "Please try again.",
color: "error",
});
}
};
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: 12px;
}
/* ---- 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;
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;
}
/* ---- FIELD HELPER TEXT ---- */
.field-help {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- YOUR POSTS LIST ---- */
.posts-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 20px;
margin-bottom: 8px;
}
.posts-heading {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.posts-new-link {
font-size: 12px;
color: var(--candle);
text-decoration: none;
}
.posts-new-link:hover {
text-decoration: underline;
}
.posts-empty {
font-size: 12px;
color: var(--text-faint);
padding: 12px 0;
border-top: 1px dashed var(--border);
}
.posts-empty-link {
color: var(--candle);
text-decoration: underline;
}
.timezone-select :deep([data-slot="placeholder"]) {
color: var(--text-dim);
}
.posts-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px dashed var(--border);
}
.post-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
}
.post-body {
flex: 1;
min-width: 0;
}
.post-title {
font-size: 13px;
color: var(--text);
margin-bottom: 2px;
}
.post-excerpt {
font-size: 11px;
color: var(--text-faint);
line-height: 1.4;
}
.post-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.post-action {
font-size: 11px;
color: var(--candle);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: none;
font-family: inherit;
}
.post-action:hover {
text-decoration: underline;
}
.post-action-danger {
color: var(--ember);
}
/* ---- RECENT ACTIVITY ---- */
.activity-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px dashed var(--border);
}
.activity-item {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.activity-time {
font-size: 10px;
color: var(--text-faint);
margin-bottom: 2px;
}
.activity-text {
color: var(--text);
}
.activity-link {
color: var(--candle);
text-decoration: none;
}
.activity-link:hover {
text-decoration: underline;
}
.activity-empty {
font-size: 12px;
color: var(--text-faint);
padding: 10px 0;
}
/* ---- DISABLED BUTTON ---- */
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---- SAVE BAR ---- */
.save-bar {
flex-shrink: 0;
padding: 24px 28px;
margin-top: 0;
border-top: 1px dashed var(--border);
display: flex;
align-items: center;
gap: 12px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.row-2 {
grid-template-columns: 1fr;
}
.save-bar {
padding-left: 16px;
padding-right: 16px;
flex-wrap: wrap;
}
}
</style>