ghostguild-org/app/pages/members.vue
Jennie Robinson Faber 1f7a0f40c0 Add Markdown support and update member features
The commit adds Markdown rendering capabilities and makes several UI/UX
improvements across member-related features including profile display,
peer support badges, and navigation structure.

Includes:
- Added @tailwindcss/typography plugin
- New Markdown rendering composable
- Simplified member navigation links
- Enhanced member profile layout and styling
- Added peer support badge component
- Improved mobile responsiveness
- Removed redundant icons and simplified UI
2025-10-07 15:07:27 +01:00

589 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<PageHeader
title="Member Directory"
subtitle="Connect with members of the Ghost Guild community"
theme="purple"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Search and Filters -->
<div class="mb-8 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<UInput
v-model="searchQuery"
placeholder="Search by name or bio..."
icon="i-heroicons-magnifying-glass"
size="lg"
@input="debouncedSearch"
/>
</div>
<!-- Circle Filter -->
<USelect
v-model="selectedCircle"
:items="circleOptions"
size="lg"
@update:model-value="loadMembers"
/>
<!-- Peer Support Filter -->
<USelect
v-model="peerSupportFilter"
:items="peerSupportOptions"
size="lg"
@update:model-value="loadMembers"
/>
</div>
<!-- Skills Filter -->
<div v-if="availableSkills && availableSkills.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-ghost-400 mr-2 self-center"
>Filter by skill:</span
>
<button
v-for="skill in (availableSkills || []).slice(
0,
showAllSkills ? undefined : 10,
)"
:key="skill"
type="button"
class="px-3 py-1 rounded-full text-sm transition-all border"
:class="
selectedSkills.includes(skill)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
"
@click="toggleSkill(skill)"
>
{{ skill }}
</button>
<button
v-if="availableSkills && availableSkills.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills"
>
{{
showAllSkills
? "Show less"
: `+${availableSkills.length - 10} more`
}}
</button>
</div>
</div>
<!-- Peer Support Topics Filter -->
<div v-if="availableTopics && availableTopics.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-ghost-400 mr-2 self-center"
>Filter by peer support topic:</span
>
<button
v-for="topic in (availableTopics || []).slice(
0,
showAllTopics ? undefined : 10,
)"
:key="topic"
type="button"
class="px-3 py-1 rounded-full text-sm transition-all border"
:class="
selectedTopics.includes(topic)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
"
@click="toggleTopic(topic)"
>
{{ topic }}
</button>
<button
v-if="availableTopics && availableTopics.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllTopics = !showAllTopics"
>
{{
showAllTopics
? "Show less"
: `+${availableTopics.length - 10} more`
}}
</button>
</div>
</div>
<!-- Active Filters -->
<div
v-if="
(selectedCircle && selectedCircle !== 'all') ||
(peerSupportFilter && peerSupportFilter !== 'all') ||
selectedSkills.length > 0 ||
selectedTopics.length > 0
"
class="flex items-center gap-2 text-sm flex-wrap"
>
<span class="text-ghost-400">Active filters:</span>
<span
v-if="selectedCircle && selectedCircle !== 'all'"
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
>
{{ circleLabels[selectedCircle] }}
<button
type="button"
class="hover:text-purple-200"
@click="clearCircleFilter"
>
×
</button>
</span>
<span
v-if="peerSupportFilter && peerSupportFilter !== 'all'"
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
>
Offering Peer Support
<button
type="button"
class="hover:text-purple-200"
@click="clearPeerSupportFilter"
>
×
</button>
</span>
<button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters"
>
Clear all filters
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if="loading && !members.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-ghost-400">Loading members...</p>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0">
<div class="mb-4 text-ghost-400 text-sm">
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div>
<div class="space-y-4">
<div
v-for="member in members"
:key="member._id"
class="relative backdrop-blur-sm bg-ghost-900/50 border border-ghost-700/50 rounded-lg p-6 hover:border-purple-500/50 transition-all group"
>
<!-- Peer Support Sticker Badge -->
<PeerSupportBadge
v-if="member.peerSupport?.enabled"
type="sticker"
/>
<!-- Header Section -->
<div class="flex items-start gap-4 mb-4">
<!-- Avatar -->
<div
class="w-16 h-16 rounded-lg bg-ghost-800 border border-ghost-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
>
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="w-12 h-12 object-contain"
/>
<span v-else class="text-2xl text-ghost-600">👻</span>
</div>
<!-- Name and Meta Info -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap mb-2">
<h3 class="font-semibold text-lg text-ghost-100">
{{ member.name }}
</h3>
<span v-if="member.pronouns" class="text-sm text-ghost-400">
{{ member.pronouns }}
</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30"
>
{{ circleLabels[member.circle] }}
</span>
<span v-if="member.studio" class="text-sm text-ghost-400">
{{ member.studio }}
</span>
<span v-if="member.location" class="text-sm text-ghost-500">
📍 {{ member.location }}
</span>
<span v-if="member.timeZone" class="text-sm text-ghost-500">
🕐 {{ member.timeZone }}
</span>
</div>
</div>
</div>
<!-- Bio -->
<div
v-if="member.bio"
class="mb-4 text-ghost-300 text-sm leading-relaxed prose prose-invert prose-sm max-w-none"
v-html="renderMarkdown(member.bio)"
></div>
<!-- Peer Support Section -->
<div
v-if="member.peerSupport?.enabled"
class="mb-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg"
>
<div class="mb-3">
<p class="text-purple-300 font-medium text-sm mb-2">
{{ member.name }} offers 1:1 chats on:
</p>
</div>
<!-- Topics -->
<div
v-if="
member.peerSupport.topics &&
member.peerSupport.topics.length > 0
"
class="mb-2"
>
<div class="flex flex-wrap gap-1">
<span
v-for="topic in member.peerSupport.topics"
:key="topic"
class="px-2 py-0.5 bg-purple-500/20 text-purple-200 rounded text-xs border border-purple-500/40"
>
{{ topic }}
</span>
</div>
</div>
<!-- Personal Message -->
<div
v-if="member.peerSupport.personalMessage"
class="text-sm text-ghost-300 italic mb-2"
>
"{{ member.peerSupport.personalMessage }}"
</div>
<!-- Availability -->
<div
v-if="member.peerSupport.availability"
class="text-xs text-ghost-400 mb-2"
>
Availability: {{ member.peerSupport.availability }}
</div>
<!-- Contact Section -->
<div v-if="member.peerSupport.slackUsername" class="space-y-2">
<p class="text-sm text-purple-300 font-medium">
Book a Peer Support call now:
</p>
<a
:href="`slack://user?team=T03A96LV4&id=${member.slackUserId}`"
@click.prevent="openSlackDM(member)"
class="inline-block px-3 py-1.5 bg-purple-500/20 text-purple-300 rounded border border-purple-500/30 hover:bg-purple-500/30 transition-colors text-sm font-medium cursor-pointer"
>
Message {{ member.peerSupport.slackUsername }} on Slack
</a>
</div>
</div>
<!-- Offering and Looking For -->
<div
v-if="member.offering || member.lookingFor"
class="space-y-4"
>
<h4
class="text-sm font-semibold text-purple-300 uppercase tracking-wide"
>
Skills Exchange
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Offering -->
<div v-if="member.offering" class="space-y-2">
<h5 class="text-xs font-semibold text-purple-400 uppercase">
Can share
</h5>
<p
v-if="member.offering.description"
class="text-ghost-300 text-sm"
>
{{ member.offering.description }}
</p>
<div
v-if="
member.offering.tags && member.offering.tags.length > 0
"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in member.offering.tags"
:key="tag"
class="px-2 py-0.5 bg-green-500/20 text-green-300 rounded text-xs border border-green-500/30"
>
{{ tag }}
</span>
</div>
</div>
<!-- Looking For -->
<div v-if="member.lookingFor" class="space-y-2">
<h5 class="text-xs font-semibold text-purple-400 uppercase">
Looking to learn
</h5>
<p
v-if="member.lookingFor.description"
class="text-ghost-300 text-sm"
>
{{ member.lookingFor.description }}
</p>
<div
v-if="
member.lookingFor.tags &&
member.lookingFor.tags.length > 0
"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in member.lookingFor.tags"
:key="tag"
class="px-2 py-0.5 bg-blue-500/20 text-blue-300 rounded text-xs border border-blue-500/30"
>
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-ghost-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-ghost-300 mb-2">
No members found
</h3>
<p class="text-ghost-400 mb-6">
Try adjusting your search or filters
</p>
<UButton variant="outline" @click="clearAllFilters">
Clear Filters
</UButton>
</div>
<!-- Not Authenticated Notice -->
<div
v-if="!isAuthenticated && members.length > 0"
class="mt-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6 text-center"
>
<p class="text-purple-200 mb-4">
🔒 Some member information is visible to members only
</p>
<div class="flex gap-3 justify-center">
<UButton to="/login" variant="outline"> Log In </UButton>
<UButton to="/join"> Join Ghost Guild </UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
const { render: renderMarkdown } = useMarkdown();
// State
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const availableTopics = ref([]);
const loading = ref(true); // Start with loading true
const searchQuery = ref("");
const selectedCircle = ref("all");
const peerSupportFilter = ref("all");
const selectedSkills = ref([]);
const selectedTopics = ref([]);
const showAllSkills = ref(false);
const showAllTopics = ref(false);
// Circle options
const circleOptions = [
{ label: "All Circles", value: "all" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
];
const circleLabels = {
community: "Community",
founder: "Founder",
practitioner: "Practitioner",
};
// Peer support filter options
const peerSupportOptions = [
{ label: "All Members", value: "all" },
{ label: "Offering Peer Support", value: "true" },
];
// Helper to check if member has social links
const hasSocialLinks = (links) => {
if (!links) return false;
return !!(links.mastodon || links.linkedin || links.website || links.other);
};
// Load members
const loadMembers = async () => {
loading.value = true;
try {
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value && selectedCircle.value !== "all")
params.circle = selectedCircle.value;
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
params.peerSupport = peerSupportFilter.value;
if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(",");
if (selectedTopics.value.length > 0)
params.topics = selectedTopics.value.join(",");
const data = await $fetch("/api/members/directory", { params });
members.value = data.members || [];
totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters?.availableSkills || [];
availableTopics.value = data.filters?.availableTopics || [];
} catch (error) {
console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
availableTopics.value = [];
} finally {
loading.value = false;
}
};
// Debounced search
let searchTimeout;
const debouncedSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadMembers();
}, 300);
};
// Toggle skill filter
const toggleSkill = (skill) => {
const index = selectedSkills.value.indexOf(skill);
if (index > -1) {
selectedSkills.value.splice(index, 1);
} else {
selectedSkills.value.push(skill);
}
loadMembers();
};
// Toggle topic filter
const toggleTopic = (topic) => {
const index = selectedTopics.value.indexOf(topic);
if (index > -1) {
selectedTopics.value.splice(index, 1);
} else {
selectedTopics.value.push(topic);
}
loadMembers();
};
// Clear filters
const clearCircleFilter = () => {
selectedCircle.value = "all";
loadMembers();
};
const clearPeerSupportFilter = () => {
peerSupportFilter.value = "all";
loadMembers();
};
const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.value = "all";
peerSupportFilter.value = "all";
selectedSkills.value = [];
selectedTopics.value = [];
loadMembers();
};
// Slack DM functionality
const openSlackDM = async (member) => {
const username = member.peerSupport?.slackUsername || member.name;
// Copy username to clipboard
try {
await navigator.clipboard.writeText(username);
} catch (err) {
console.log("Could not copy to clipboard:", err);
}
// Show alert and open Slack
alert(
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
);
window.open("https://gammaspace.slack.com", "_blank");
};
// Load on mount
onMounted(() => {
loadMembers();
});
useHead({
title: "Member Directory - Ghost Guild",
meta: [
{
name: "description",
content:
"Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.",
},
],
});
</script>