Adding features
This commit is contained in:
parent
600fef2b7c
commit
2b55ca4104
75 changed files with 9796 additions and 2759 deletions
400
app/pages/members.vue
Normal file
400
app/pages/members.vue
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<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-3 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"
|
||||
:options="circleOptions"
|
||||
placeholder="All Circles"
|
||||
size="lg"
|
||||
@change="loadMembers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Skills Filter -->
|
||||
<div v-if="availableSkills.length > 0">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="text-sm text-stone-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-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
|
||||
"
|
||||
@click="toggleSkill(skill)"
|
||||
>
|
||||
{{ skill }}
|
||||
</button>
|
||||
<button
|
||||
v-if="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>
|
||||
|
||||
<!-- Active Filters -->
|
||||
<div
|
||||
v-if="selectedCircle || selectedSkills.length > 0"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="text-stone-400">Active filters:</span>
|
||||
<span
|
||||
v-if="selectedCircle"
|
||||
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>
|
||||
<button
|
||||
v-if="selectedSkills.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-stone-400">Loading members...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0">
|
||||
<div class="mb-4 text-stone-400 text-sm">
|
||||
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member._id"
|
||||
class="backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-4 hover:border-purple-500/50 transition-all group flex items-center gap-4"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-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-8 h-8 object-contain"
|
||||
/>
|
||||
<span v-else class="text-xl text-stone-600">👻</span>
|
||||
</div>
|
||||
|
||||
<!-- Name and Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<NuxtLink
|
||||
:to="`/updates/user/${member._id}`"
|
||||
class="font-semibold text-stone-100 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
{{ member.name }}
|
||||
</NuxtLink>
|
||||
<span v-if="member.pronouns" class="text-sm text-stone-400">
|
||||
{{ member.pronouns }}
|
||||
</span>
|
||||
<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-stone-400">
|
||||
{{ member.studio }}
|
||||
</span>
|
||||
<span v-if="member.location" class="text-sm text-stone-500">
|
||||
{{ member.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div
|
||||
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
|
||||
class="flex gap-3 flex-shrink-0"
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.mastodon"
|
||||
:href="member.socialLinks.mastodon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
title="Mastodon"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
v-if="member.socialLinks.linkedin"
|
||||
:href="member.socialLinks.linkedin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
v-if="member.socialLinks.website"
|
||||
:href="member.socialLinks.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
title="Website"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
v-if="member.socialLinks.other"
|
||||
:href="member.socialLinks.other"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
title="Other link"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</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-stone-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-stone-300 mb-2">
|
||||
No members found
|
||||
</h3>
|
||||
<p class="text-stone-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();
|
||||
|
||||
// State
|
||||
const members = ref([]);
|
||||
const totalCount = ref(0);
|
||||
const availableSkills = ref([]);
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const selectedCircle = ref("");
|
||||
const selectedSkills = ref([]);
|
||||
const showAllSkills = ref(false);
|
||||
|
||||
// Circle options
|
||||
const circleOptions = [
|
||||
{ label: "All Circles", value: "" },
|
||||
{ label: "Community", value: "community" },
|
||||
{ label: "Founder", value: "founder" },
|
||||
{ label: "Practitioner", value: "practitioner" },
|
||||
];
|
||||
|
||||
const circleLabels = {
|
||||
community: "Community",
|
||||
founder: "Founder",
|
||||
practitioner: "Practitioner",
|
||||
};
|
||||
|
||||
// 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) params.circle = selectedCircle.value;
|
||||
if (selectedSkills.value.length > 0)
|
||||
params.skills = selectedSkills.value.join(",");
|
||||
|
||||
const data = await $fetch("/api/members/directory", { params });
|
||||
|
||||
members.value = data.members;
|
||||
totalCount.value = data.totalCount;
|
||||
availableSkills.value = data.filters.availableSkills;
|
||||
} catch (error) {
|
||||
console.error("Failed to load members:", error);
|
||||
} 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();
|
||||
};
|
||||
|
||||
// Clear filters
|
||||
const clearCircleFilter = () => {
|
||||
selectedCircle.value = "";
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
searchQuery.value = "";
|
||||
selectedCircle.value = "";
|
||||
selectedSkills.value = [];
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
// 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue