Add light/dark mode support with CSS variables

This commit is contained in:
Jennie Robinson Faber 2025-10-06 19:54:20 +01:00
parent 970b185151
commit fb02688166
25 changed files with 1293 additions and 1177 deletions

View file

@ -11,7 +11,7 @@
<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">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<UInput
@ -26,17 +26,24 @@
<!-- Circle Filter -->
<USelect
v-model="selectedCircle"
:options="circleOptions"
placeholder="All Circles"
:items="circleOptions"
size="lg"
@change="loadMembers"
@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-stone-400 mr-2 self-center"
<span class="text-sm text-ghost-400 mr-2 self-center"
>Filter by skill:</span
>
<button
@ -50,7 +57,7 @@
: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'
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
"
@click="toggleSkill(skill)"
>
@ -71,12 +78,55 @@
</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 || selectedSkills.length > 0"
class="flex items-center gap-2 text-sm"
v-if="
selectedCircle ||
peerSupportFilter ||
selectedSkills.length > 0 ||
selectedTopics.length > 0
"
class="flex items-center gap-2 text-sm flex-wrap"
>
<span class="text-stone-400">Active filters:</span>
<span class="text-ghost-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"
@ -90,8 +140,21 @@
×
</button>
</span>
<span
v-if="peerSupportFilter"
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"
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters"
@ -110,138 +173,282 @@
<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>
<p class="text-ghost-400">Loading members...</p>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0">
<div class="mb-4 text-stone-400 text-sm">
<div class="mb-4 text-ghost-400 text-sm">
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div>
<div class="space-y-2">
<div class="space-y-4">
<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"
class="backdrop-blur-sm bg-ghost-900/50 border border-ghost-700/50 rounded-lg p-6 hover:border-purple-500/50 transition-all group"
>
<!-- 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>
<!-- 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 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"
<!-- Name and Meta Info -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap mb-2">
<NuxtLink
:to="`/updates/user/${member._id}`"
class="font-semibold text-lg text-ghost-100 hover:text-purple-300 transition-colors"
>
{{ member.name }}
</NuxtLink>
<span v-if="member.pronouns" class="text-sm text-ghost-400">
{{ member.pronouns }}
</span>
</div>
<div class="flex items-center gap-2 flex-wrap mb-2">
<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>
<!-- Social Links -->
<div
v-if="
member.socialLinks && hasSocialLinks(member.socialLinks)
"
class="flex gap-3"
>
{{ 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>
<a
v-if="member.socialLinks.mastodon"
:href="member.socialLinks.mastodon"
target="_blank"
rel="noopener noreferrer"
class="text-ghost-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-ghost-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-ghost-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-ghost-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>
<!-- Social Links -->
<!-- Bio -->
<div v-if="member.bio" class="mb-4">
<p class="text-ghost-300 text-sm leading-relaxed">
{{ member.bio }}
</p>
</div>
<!-- Peer Support Section -->
<div
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
class="flex gap-3 flex-shrink-0"
v-if="member.peerSupport?.enabled"
class="mb-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg"
>
<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"
<div class="flex items-center gap-2 mb-2">
<span class="text-purple-300 font-medium text-sm">
💜 Offering Peer Support
</span>
</div>
<!-- Topics -->
<div
v-if="
member.peerSupport.topics &&
member.peerSupport.topics.length > 0
"
class="mb-2"
>
<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>
<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 Button -->
<a
v-if="member.peerSupport.slackUsername"
: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>
<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"
</div>
<!-- Offering and Looking For -->
<div
v-if="member.offering || member.lookingFor"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<!-- Offering -->
<div v-if="member.offering" class="space-y-2">
<h4 class="text-xs font-semibold text-purple-400 uppercase">
Offering
</h4>
<p
v-if="member.offering.description"
class="text-ghost-300 text-sm"
>
<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"
{{ member.offering.description }}
</p>
<div
v-if="
member.offering.tags && member.offering.tags.length > 0
"
class="flex flex-wrap gap-1"
>
<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>
<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">
<h4 class="text-xs font-semibold text-purple-400 uppercase">
Looking For
</h4>
<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>
@ -254,7 +461,7 @@
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
class="text-ghost-600"
>
<path
stroke-linecap="round"
@ -264,10 +471,10 @@
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
<h3 class="text-lg font-medium text-ghost-300 mb-2">
No members found
</h3>
<p class="text-stone-400 mb-6">
<p class="text-ghost-400 mb-6">
Try adjusting your search or filters
</p>
<UButton variant="outline" @click="clearAllFilters">
@ -300,15 +507,19 @@ const { isAuthenticated } = useAuth();
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("");
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: "" },
{ label: "All Circles", value: "all" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
@ -320,6 +531,12 @@ const circleLabels = {
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;
@ -333,20 +550,27 @@ const loadMembers = async () => {
try {
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value) params.circle = selectedCircle.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;
}
@ -372,19 +596,53 @@ const toggleSkill = (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 = "";
loadMembers();
selectedCircle.value = "all";
};
const clearPeerSupportFilter = () => {
peerSupportFilter.value = "all";
};
const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.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();