The changes involve a comprehensive interface redesign across multiple pages, including: - Updated peer support badge with shield design - Switched privacy toggle to use USwitch component - Added light/dark mode support throughout - Enhanced layout and spacing in default template - Added series details page with timeline view - Improved event cards and status indicators - Refreshed member profile styles for better readability - Introduced global cursor styling for interactive elements
595 lines
21 KiB
Vue
595 lines
21 KiB
Vue
<template>
|
||
<div>
|
||
<PageHeader
|
||
title="Member Directory"
|
||
subtitle="Connect with members of the Ghost Guild community"
|
||
theme="purple"
|
||
size="medium"
|
||
/>
|
||
|
||
<section class="">
|
||
<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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-500/50'
|
||
: 'bg-gray-100 dark:bg-ghost-800/50 text-gray-700 dark:text-ghost-400 border-gray-300 dark:border-ghost-700 hover:border-gray-400 dark: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-primary hover:text-primary-600"
|
||
@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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-500/50'
|
||
: 'bg-gray-100 dark:bg-ghost-800/50 text-gray-700 dark:text-ghost-400 border-gray-300 dark:border-ghost-700 hover:border-gray-400 dark: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-primary hover:text-primary-600"
|
||
@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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full border border-purple-300 dark:border-purple-500/30 flex items-center gap-1"
|
||
>
|
||
{{ circleLabels[selectedCircle] }}
|
||
<button
|
||
type="button"
|
||
class="hover:text-primary"
|
||
@click="clearCircleFilter"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
<span
|
||
v-if="peerSupportFilter && peerSupportFilter !== 'all'"
|
||
class="px-2 py-1 bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full border border-purple-300 dark:border-purple-500/30 flex items-center gap-1"
|
||
>
|
||
Offering Peer Support
|
||
<button
|
||
type="button"
|
||
class="hover:text-primary"
|
||
@click="clearPeerSupportFilter"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
<button
|
||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
||
type="button"
|
||
class="text-primary hover:text-primary-600"
|
||
@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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded text-xs border border-purple-300 dark: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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-200 rounded text-xs border border-purple-300 dark: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-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded border border-purple-300 dark:border-purple-500/30 hover:bg-purple-200 dark: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-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded text-xs border border-green-300 dark: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-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded text-xs border border-blue-300 dark: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 and handle query params
|
||
onMounted(() => {
|
||
// Check for peerSupport query parameter
|
||
const route = useRoute();
|
||
if (route.query.peerSupport === "true") {
|
||
peerSupportFilter.value = "true";
|
||
}
|
||
|
||
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>
|