Update CSS comment from "Community Connections" to "Community Ecology" in member detail page. Docs, CLAUDE.md, and activity log spec also updated (gitignored, local only).
718 lines
18 KiB
Vue
718 lines
18 KiB
Vue
<template>
|
|
<PageShell>
|
|
<!-- Loading State -->
|
|
<div v-if="pending" class="loading-state">
|
|
<p>Loading profile...</p>
|
|
</div>
|
|
|
|
<!-- Error / 404 State -->
|
|
<div v-else-if="fetchError || !member" class="error-state">
|
|
<p class="error-title">Member not found</p>
|
|
<p class="error-sub">This profile doesn't exist or isn't public.</p>
|
|
<NuxtLink to="/members" class="btn">← Back to Members</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Profile Content -->
|
|
<template v-else>
|
|
|
|
<!-- HERO: full-bleed, outside ColumnsLayout -->
|
|
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
|
|
|
|
<!-- Left: Avatar + Identity -->
|
|
<div class="profile-hero-left">
|
|
<div class="profile-avatar">
|
|
<img
|
|
v-if="member.avatar"
|
|
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
|
:alt="member.name"
|
|
class="profile-avatar-img"
|
|
/>
|
|
<span v-else class="profile-initials">{{ getInitials(member.name) }}</span>
|
|
</div>
|
|
<div class="profile-identity">
|
|
<h1 class="profile-name">
|
|
{{ member.name }}<span v-if="member.memberNumber" class="profile-member-number">#{{ member.memberNumber }}</span>
|
|
</h1>
|
|
<div v-if="member.pronouns" class="profile-pronouns-row">
|
|
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
|
</div>
|
|
<div class="profile-meta">
|
|
<span v-if="member.circle" class="badge" :class="member.circle">
|
|
{{ circleLabels[member.circle] }}
|
|
</span>
|
|
<template v-if="member.studio">
|
|
<span class="meta-sep">·</span>
|
|
<span class="profile-studio">{{ member.studio }}</span>
|
|
</template>
|
|
<template v-if="member.location || member.timeZone">
|
|
<span class="meta-sep">·</span>
|
|
<span class="profile-location">
|
|
{{ [member.location, member.timeZone].filter(Boolean).join(' · ') }}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Social Links (only when present) -->
|
|
<div v-if="hasSocialLinks" class="profile-hero-right">
|
|
<div class="section-label">Links</div>
|
|
<div class="social-links">
|
|
<a
|
|
v-if="member.socialLinks.website"
|
|
:href="member.socialLinks.website"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="social-link"
|
|
>Website</a>
|
|
<a
|
|
v-if="member.socialLinks.itch"
|
|
:href="member.socialLinks.itch"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="social-link"
|
|
>itch.io</a>
|
|
<a
|
|
v-if="member.socialLinks.mastodon"
|
|
:href="member.socialLinks.mastodon"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="social-link"
|
|
>Mastodon</a>
|
|
<a
|
|
v-if="member.socialLinks.bluesky"
|
|
:href="member.socialLinks.bluesky"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="social-link"
|
|
>Bluesky</a>
|
|
<a
|
|
v-if="member.socialLinks.linkedin"
|
|
:href="member.socialLinks.linkedin"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="social-link"
|
|
>LinkedIn</a>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<!-- END HERO -->
|
|
|
|
<!-- ColumnsLayout wraps all remaining sections -->
|
|
<ColumnsLayout cols="events-sidebar">
|
|
|
|
<!-- Bio: parch (inverted) block -->
|
|
<div v-if="member.bio" class="profile-section profile-section--parch">
|
|
<div class="section-label">About</div>
|
|
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
|
</div>
|
|
|
|
<!-- Two-column: Craft Tags + Community Ecology -->
|
|
<div
|
|
v-if="craftTagsDisplay.length > 0 || ecologyTopics.length > 0 || member.communityEcology?.details"
|
|
class="profile-two-col"
|
|
>
|
|
<!-- Left: What I Do -->
|
|
<div class="profile-section">
|
|
<div class="section-label">What I Do</div>
|
|
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
|
<span
|
|
v-for="tag in craftTagsDisplay"
|
|
:key="tag"
|
|
class="tag-pill"
|
|
>{{ tagLabel('craft', tag) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Community Ecology -->
|
|
<div class="profile-section">
|
|
<div class="section-label">Community Ecology</div>
|
|
<div v-if="ecologyTopics.length > 0" class="tag-list">
|
|
<span
|
|
v-for="topic in ecologyTopics"
|
|
:key="topic.tagSlug"
|
|
class="tag-pill connection-pill"
|
|
>
|
|
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
|
|
{{ tagLabel('cooperative', topic.tagSlug) }}
|
|
</span>
|
|
</div>
|
|
<p v-if="member.communityEcology?.details" class="profile-detail connection-details">
|
|
{{ member.communityEcology.details }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Peer Support -->
|
|
<div v-if="member.communityEcology?.offerPeerSupport" class="profile-section">
|
|
<div class="section-label">Peer Support</div>
|
|
<div class="dashed-box no-hover">
|
|
<p v-if="member.communityEcology?.personalMessage" class="profile-detail">
|
|
{{ member.communityEcology.personalMessage }}
|
|
</p>
|
|
<p v-if="member.communityEcology?.availability" class="profile-detail peer-availability">
|
|
{{ member.communityEcology.availability }}
|
|
</p>
|
|
<p v-if="member.communityEcology?.slackHandle" class="profile-detail peer-availability">
|
|
Reach out on Slack: <span class="slack-handle">@{{ member.communityEcology.slackHandle }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Activity -->
|
|
<div v-if="activityEntries.length" class="profile-section">
|
|
<div class="section-label">Recent Activity</div>
|
|
<div class="activity-timeline">
|
|
<div v-for="entry in activityEntries" :key="entry._id" class="activity-entry">
|
|
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
|
|
<div class="activity-body">
|
|
<span class="activity-text">{{ getActivity(entry).text }}</span>
|
|
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auth Notice -->
|
|
<div v-if="!isAuthenticated" class="profile-section">
|
|
<div class="auth-notice">
|
|
<p>Sign in to see full profile details</p>
|
|
<button
|
|
type="button"
|
|
class="btn"
|
|
@click="openLoginModal({ title: 'Sign in to see more', description: 'Log in to view full member profiles' })"
|
|
>
|
|
Log In
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Back Link -->
|
|
<div class="profile-back">
|
|
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
|
</div>
|
|
|
|
</ColumnsLayout>
|
|
|
|
</template>
|
|
</PageShell>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { formatActivity } from '~/utils/activityText'
|
|
|
|
const route = useRoute();
|
|
const { isAuthenticated } = useAuth();
|
|
const { openLoginModal } = useLoginModal();
|
|
const { render: renderMarkdown } = useMarkdown();
|
|
|
|
const id = route.params.id;
|
|
|
|
const circleLabels = {
|
|
community: "Community",
|
|
founder: "Founder",
|
|
practitioner: "Practitioner",
|
|
};
|
|
|
|
// State display text mapping
|
|
const stateLabels = {
|
|
help: "Can help",
|
|
interested: "Interested",
|
|
seeking: "Need help",
|
|
};
|
|
|
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
|
|
|
const getInitials = (name) => {
|
|
if (!name) return "?";
|
|
return name
|
|
.split(" ")
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
};
|
|
|
|
// Fetch member data — no await so the component renders immediately (no Suspense)
|
|
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
|
|
|
|
// Fetch tags for slug-to-label lookup
|
|
const { data: tagsData } = useFetch("/api/tags", {
|
|
default: () => ({ tags: [] }),
|
|
});
|
|
|
|
// Fetch public activity
|
|
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
|
|
params: { limit: 5 },
|
|
default: () => ({ entries: [] })
|
|
})
|
|
const activityEntries = computed(() => activityData.value?.entries || [])
|
|
|
|
const getActivity = (entry) => formatActivity(entry)
|
|
|
|
const formatRelativeDate = (date) => {
|
|
const now = new Date()
|
|
const d = new Date(date)
|
|
const diffInSeconds = Math.floor((now - d) / 1000)
|
|
if (diffInSeconds < 60) return 'just now'
|
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
|
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
|
|
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
}
|
|
const member = computed(() => data.value?.member || null);
|
|
|
|
// Tag label lookup
|
|
const tagLabel = (pool, slug) => {
|
|
const tags = tagsData.value?.tags || [];
|
|
const found = tags.find((t) => t.slug === slug && t.pool === pool);
|
|
return found ? found.label : slug;
|
|
};
|
|
|
|
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
|
|
|
|
const ecologyTopics = computed(
|
|
() => member.value?.communityEcology?.topics || [],
|
|
);
|
|
|
|
// Whether the member has any social links (for hero layout)
|
|
const hasSocialLinks = computed(() =>
|
|
member.value?.socialLinks && Object.values(member.value.socialLinks).some(Boolean)
|
|
)
|
|
|
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
|
watch(
|
|
member,
|
|
(val) => {
|
|
pageBreadcrumbTitle.value = val?.name || "";
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
onUnmounted(() => {
|
|
pageBreadcrumbTitle.value = "";
|
|
});
|
|
|
|
// Page head
|
|
useHead({
|
|
title: computed(() =>
|
|
member.value
|
|
? `${member.value.name} — Ghost Guild`
|
|
: "Member Profile — Ghost Guild",
|
|
),
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ---- LOADING STATE ---- */
|
|
.loading-state {
|
|
padding: 80px 32px;
|
|
text-align: center;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ---- ERROR / 404 STATE ---- */
|
|
.error-state {
|
|
padding: 80px 32px;
|
|
text-align: center;
|
|
}
|
|
.error-title {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 20px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 6px;
|
|
}
|
|
.error-sub {
|
|
font-size: 12px;
|
|
color: var(--text-faint);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* ====================================================
|
|
HERO — full-bleed, two-column when social links exist
|
|
==================================================== */
|
|
|
|
.profile-hero {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.profile-hero--with-links {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
|
|
.profile-hero-left {
|
|
padding: 32px 32px 28px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.profile-hero--with-links .profile-hero-left {
|
|
border-right: 1px dashed var(--border);
|
|
}
|
|
|
|
.profile-hero-right {
|
|
padding: 32px;
|
|
}
|
|
|
|
/* Avatar */
|
|
.profile-avatar {
|
|
width: 96px;
|
|
height: 96px;
|
|
background: var(--surface);
|
|
border: 1px dashed var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
.profile-avatar-img {
|
|
width: 86px;
|
|
height: 86px;
|
|
object-fit: contain;
|
|
}
|
|
.profile-initials {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 28px;
|
|
color: var(--text-faint);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Identity */
|
|
.profile-identity {
|
|
min-width: 0;
|
|
}
|
|
.profile-name {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 42px;
|
|
font-weight: 600;
|
|
color: var(--text-bright);
|
|
margin: 0;
|
|
line-height: 1.1;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.profile-member-number {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
color: var(--text-faint);
|
|
letter-spacing: 0.02em;
|
|
margin-left: 10px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.profile-pronouns-row {
|
|
margin-top: 4px;
|
|
}
|
|
.profile-pronouns {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.profile-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
flex-wrap: wrap;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
.meta-sep {
|
|
color: var(--border);
|
|
}
|
|
.profile-studio,
|
|
.profile-location {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* Social links — vertical stack in hero right column */
|
|
.social-links {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-top: 4px;
|
|
}
|
|
.social-link {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 11px;
|
|
color: var(--candle);
|
|
text-decoration: none;
|
|
padding: 5px 12px;
|
|
border: 1px dashed var(--border);
|
|
transition: border-color 0.15s, color 0.15s, border-style 0.15s;
|
|
display: block;
|
|
}
|
|
.social-link:hover {
|
|
border-color: var(--candle);
|
|
border-style: solid;
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
/* ====================================================
|
|
SECTIONS — inside ColumnsLayout
|
|
==================================================== */
|
|
|
|
.profile-section {
|
|
padding: 28px 32px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
/* Bio: parch (inverted) block */
|
|
.profile-section--parch {
|
|
background: var(--parch);
|
|
}
|
|
.profile-section--parch .section-label {
|
|
color: var(--parch-text-dim);
|
|
}
|
|
.profile-section--parch .profile-bio {
|
|
color: var(--parch-text);
|
|
}
|
|
.profile-section--parch .profile-bio :deep(a) {
|
|
color: var(--candle-faint);
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.profile-section--parch .profile-bio :deep(a:hover) {
|
|
color: var(--parch-text);
|
|
}
|
|
|
|
.profile-bio {
|
|
font-size: 13px;
|
|
line-height: 1.75;
|
|
color: var(--text-dim);
|
|
}
|
|
.profile-bio :deep(p) {
|
|
margin: 0 0 10px;
|
|
}
|
|
.profile-bio :deep(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
.profile-bio :deep(a) {
|
|
color: var(--candle);
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.profile-bio :deep(a:hover) {
|
|
color: var(--ember);
|
|
}
|
|
|
|
/* ====================================================
|
|
TWO-COLUMN: Craft Tags + Community Ecology
|
|
==================================================== */
|
|
|
|
.profile-two-col {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
.profile-two-col .profile-section {
|
|
border-bottom: none;
|
|
}
|
|
.profile-two-col .profile-section:first-child {
|
|
border-right: 1px dashed var(--border);
|
|
}
|
|
|
|
/* ====================================================
|
|
SHARED SECTION ELEMENTS
|
|
==================================================== */
|
|
|
|
.profile-detail {
|
|
font-size: 13px;
|
|
color: var(--text-dim);
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
}
|
|
.connection-details {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
/* Tags */
|
|
.tag-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.tag-pill {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
padding: 3px 8px;
|
|
border: 1px dashed var(--border);
|
|
white-space: nowrap;
|
|
}
|
|
.connection-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.connection-state {
|
|
font-size: 9px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
/* ====================================================
|
|
PEER SUPPORT
|
|
==================================================== */
|
|
|
|
.peer-availability {
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px dashed var(--border);
|
|
}
|
|
.slack-handle {
|
|
font-family: "Commit Mono", monospace;
|
|
color: var(--candle-dim);
|
|
}
|
|
|
|
/* ====================================================
|
|
ACTIVITY TIMELINE
|
|
==================================================== */
|
|
|
|
.activity-timeline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-left: 1px dashed var(--border);
|
|
margin-left: 6px;
|
|
}
|
|
.activity-entry {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 8px 0 8px 16px;
|
|
position: relative;
|
|
}
|
|
/* Dot connector on the timeline track */
|
|
.activity-entry::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: -4px;
|
|
top: 14px;
|
|
width: 6px;
|
|
height: 6px;
|
|
border: 1px dashed var(--border);
|
|
background: var(--bg);
|
|
}
|
|
.activity-icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
color: var(--text-faint);
|
|
flex-shrink: 0;
|
|
margin-top: 1px;
|
|
}
|
|
.activity-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
.activity-text {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
line-height: 1.5;
|
|
}
|
|
.activity-time {
|
|
font-size: 10px;
|
|
color: var(--text-faint);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
/* ====================================================
|
|
AUTH NOTICE
|
|
==================================================== */
|
|
|
|
.auth-notice {
|
|
border: 1px dashed var(--border);
|
|
padding: 24px;
|
|
text-align: center;
|
|
}
|
|
.auth-notice p {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin: 0 0 12px;
|
|
}
|
|
|
|
/* ====================================================
|
|
BACK LINK
|
|
==================================================== */
|
|
|
|
.profile-back {
|
|
padding: 24px 32px;
|
|
}
|
|
.back-link {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 12px;
|
|
color: var(--text-faint);
|
|
text-decoration: none;
|
|
transition: color 0.15s;
|
|
}
|
|
.back-link:hover {
|
|
color: var(--candle);
|
|
}
|
|
|
|
/* ====================================================
|
|
RESPONSIVE
|
|
==================================================== */
|
|
|
|
@media (max-width: 1024px) {
|
|
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
|
|
.profile-two-col {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.profile-two-col .profile-section:first-child {
|
|
border-right: none;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.profile-hero,
|
|
.profile-hero--with-links {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.profile-hero--with-links .profile-hero-left {
|
|
border-right: none;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
.profile-hero-left {
|
|
padding: 24px 20px;
|
|
gap: 16px;
|
|
}
|
|
.profile-hero-right {
|
|
padding: 20px;
|
|
}
|
|
.profile-name {
|
|
font-size: 28px;
|
|
}
|
|
.profile-avatar {
|
|
width: 72px;
|
|
height: 72px;
|
|
}
|
|
.profile-avatar-img {
|
|
width: 64px;
|
|
height: 64px;
|
|
}
|
|
.profile-initials {
|
|
font-size: 20px;
|
|
}
|
|
.profile-section {
|
|
padding: 20px;
|
|
}
|
|
.profile-back {
|
|
padding: 20px;
|
|
}
|
|
.social-links {
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|