feat: add craft tags and community connections to directory and profiles
Update member directory and public profile APIs to include craftTags and communityConnections with privacy-aware filtering. Directory now uses predefined tags from the Tag model for filter bars and supports craftTag/connectionTag query filters. Frontend shows craft tag pills and cooperative topics with state labels, falling back to old offering/lookingFor fields. Add Connections nav item.
This commit is contained in:
parent
bd07172093
commit
896de2e7fd
5 changed files with 367 additions and 138 deletions
|
|
@ -173,6 +173,7 @@ const youItems = [
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
{ label: "Events", path: "/events" },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: "Members", path: "/members" },
|
{ label: "Members", path: "/members" },
|
||||||
|
{ label: "Connections", path: "/connections" },
|
||||||
{ label: "Wiki", path: "/wiki" },
|
{ label: "Wiki", path: "/wiki" },
|
||||||
{ label: "About", path: "/about" },
|
{ label: "About", path: "/about" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -60,40 +60,42 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Offering Section -->
|
<!-- What I Do (craft tags, falling back to offering) -->
|
||||||
<div
|
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
|
||||||
v-if="member.offering?.tags?.length || member.offering?.text"
|
<div class="section-label">What I Do</div>
|
||||||
class="profile-section"
|
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
||||||
>
|
|
||||||
<div class="section-label">Offering</div>
|
|
||||||
<div v-if="member.offering.tags?.length" class="tag-list">
|
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.offering.tags"
|
v-for="tag in craftTagsDisplay"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="tag-pill"
|
class="tag-pill"
|
||||||
>{{ tag }}</span
|
>{{ tagLabel('craft', tag) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.offering.text" class="profile-detail offering-text">
|
<p v-if="member.offering?.text" class="profile-detail offering-text">
|
||||||
{{ member.offering.text }}
|
{{ member.offering.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Looking For Section -->
|
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text"
|
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
|
||||||
class="profile-section"
|
class="profile-section"
|
||||||
>
|
>
|
||||||
<div class="section-label">Looking for</div>
|
<div class="section-label">Community Connections</div>
|
||||||
<div v-if="member.lookingFor.tags?.length" class="tag-list">
|
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.lookingFor.tags"
|
v-for="topic in connectionTopicsDisplay"
|
||||||
:key="tag"
|
:key="topic.tagSlug || topic"
|
||||||
class="tag-pill"
|
class="tag-pill connection-pill"
|
||||||
>{{ tag }}</span
|
|
||||||
>
|
>
|
||||||
|
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.lookingFor.text" class="profile-detail looking-text">
|
<p v-if="member.communityConnections?.details" class="profile-detail connection-details">
|
||||||
|
{{ member.communityConnections.details }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
|
||||||
{{ member.lookingFor.text }}
|
{{ member.lookingFor.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,10 +152,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer Support Section -->
|
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
|
||||||
<div v-if="member.peerSupport?.enabled" class="profile-section">
|
<div v-if="showPeerSupport" class="profile-section">
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
<div v-if="member.peerSupport.skillTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Skills:</span>
|
<span class="peer-label">Skills:</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
|
|
@ -164,7 +166,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member.peerSupport.supportTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Topics:</span>
|
<span class="peer-label">Topics:</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
|
|
@ -175,8 +177,8 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.peerSupport.availability" class="profile-detail">
|
<p v-if="peerAvailability" class="profile-detail">
|
||||||
{{ member.peerSupport.availability }}
|
{{ peerAvailability }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -233,6 +235,15 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
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) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
|
|
@ -246,6 +257,11 @@ const getInitials = (name) => {
|
||||||
// Fetch member data — no await so the component renders immediately (no Suspense)
|
// Fetch member data — no await so the component renders immediately (no Suspense)
|
||||||
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
|
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
|
// Fetch public activity
|
||||||
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
|
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
|
||||||
params: { limit: 5 },
|
params: { limit: 5 },
|
||||||
|
|
@ -267,6 +283,56 @@ const formatRelativeDate = (date) => {
|
||||||
}
|
}
|
||||||
const member = computed(() => data.value?.member || null);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Craft tags display: new field, falling back to offering.tags
|
||||||
|
const craftTagsDisplay = computed(() => {
|
||||||
|
if (!member.value) return [];
|
||||||
|
if (member.value.craftTags && member.value.craftTags.length > 0) {
|
||||||
|
return member.value.craftTags;
|
||||||
|
}
|
||||||
|
return member.value.offering?.tags || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection topics display: new field, falling back to lookingFor.tags
|
||||||
|
const connectionTopicsDisplay = computed(() => {
|
||||||
|
if (!member.value) return [];
|
||||||
|
if (
|
||||||
|
member.value.communityConnections?.topics &&
|
||||||
|
member.value.communityConnections.topics.length > 0
|
||||||
|
) {
|
||||||
|
return member.value.communityConnections.topics;
|
||||||
|
}
|
||||||
|
if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) {
|
||||||
|
return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peer support: check both new communityConnections and old peerSupport
|
||||||
|
const showPeerSupport = computed(() => {
|
||||||
|
if (!member.value) return false;
|
||||||
|
return (
|
||||||
|
member.value.communityConnections?.offerPeerSupport ||
|
||||||
|
member.value.peerSupport?.enabled
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peer availability: prefer new field, fall back to old
|
||||||
|
const peerAvailability = computed(() => {
|
||||||
|
if (!member.value) return "";
|
||||||
|
return (
|
||||||
|
member.value.communityConnections?.availability ||
|
||||||
|
member.value.peerSupport?.availability ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||||
watch(
|
watch(
|
||||||
member,
|
member,
|
||||||
|
|
@ -442,7 +508,8 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.offering-text,
|
.offering-text,
|
||||||
.looking-text {
|
.looking-text,
|
||||||
|
.connection-details {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,6 +529,19 @@ useHead({
|
||||||
white-space: nowrap;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- SOCIAL LINKS ---- */
|
/* ---- SOCIAL LINKS ---- */
|
||||||
.social-links {
|
.social-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -41,64 +41,64 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills Filter -->
|
<!-- Craft Tags Filter -->
|
||||||
<div
|
<div
|
||||||
v-if="availableSkills && availableSkills.length > 0"
|
v-if="craftTagOptions.length > 0"
|
||||||
class="skills-bar"
|
class="skills-bar"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Skills:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<button
|
<button
|
||||||
v-for="skill in (availableSkills || []).slice(
|
v-for="tag in craftTagOptions.slice(
|
||||||
0,
|
0,
|
||||||
showAllSkills ? undefined : 10,
|
showAllCraftTags ? undefined : 10,
|
||||||
)"
|
)"
|
||||||
:key="skill"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: selectedSkills.includes(skill) }"
|
:class="{ active: selectedCraftTags.includes(tag.slug) }"
|
||||||
@click="toggleSkill(skill)"
|
@click="toggleCraftTag(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ skill }}
|
{{ tag.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="availableSkills && availableSkills.length > 10"
|
v-if="craftTagOptions.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllSkills = !showAllSkills"
|
@click="showAllCraftTags = !showAllCraftTags"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
|
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Topics Filter -->
|
<!-- Connection Tags Filter -->
|
||||||
<div
|
<div
|
||||||
v-if="availableTopics && availableTopics.length > 0"
|
v-if="connectionTagOptions.length > 0"
|
||||||
class="skills-bar"
|
class="skills-bar"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Topics:</span>
|
<span class="tag-label">Topics:</span>
|
||||||
<button
|
<button
|
||||||
v-for="topic in (availableTopics || []).slice(
|
v-for="tag in connectionTagOptions.slice(
|
||||||
0,
|
0,
|
||||||
showAllTopics ? undefined : 10,
|
showAllConnectionTags ? undefined : 10,
|
||||||
)"
|
)"
|
||||||
:key="topic"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: selectedTopics.includes(topic) }"
|
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
|
||||||
@click="toggleTopic(topic)"
|
@click="toggleConnectionTag(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ topic }}
|
{{ tag.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="availableTopics && availableTopics.length > 10"
|
v-if="connectionTagOptions.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllTopics = !showAllTopics"
|
@click="showAllConnectionTags = !showAllConnectionTags"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
|
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,16 +117,16 @@
|
||||||
Offering Peer Support
|
Offering Peer Support
|
||||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
|
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
|
||||||
{{ skill }}
|
{{ craftTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleSkill(skill)">×</button>
|
<button type="button" @click="toggleCraftTag(slug)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
|
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
|
||||||
{{ topic }}
|
{{ connectionTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleTopic(topic)">×</button>
|
<button type="button" @click="toggleConnectionTag(slug)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="clear-all-btn"
|
class="clear-all-btn"
|
||||||
@click="clearAllFilters"
|
@click="clearAllFilters"
|
||||||
|
|
@ -186,33 +186,38 @@
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills tags -->
|
<!-- Craft tags (fall back to offering.tags) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.offering?.tags && member.offering.tags.length > 0"
|
v-if="getMemberCraftTags(member).length > 0"
|
||||||
class="mc-tags"
|
class="mc-tags"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Skills:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.offering.tags"
|
v-for="tag in getMemberCraftTags(member)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
>{{ tag }}</span
|
>{{ craftTagLabel(tag) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Looking for -->
|
<!-- Community connections topics (fall back to lookingFor.tags) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
v-if="getMemberConnectionTopics(member).length > 0"
|
||||||
class="mc-looking"
|
class="mc-looking"
|
||||||
>
|
>
|
||||||
Looking for: {{ member.lookingFor.tags.join(", ") }}
|
<span
|
||||||
|
v-for="topic in getMemberConnectionTopics(member)"
|
||||||
|
:key="topic.tagSlug || topic"
|
||||||
|
class="connection-topic"
|
||||||
|
>
|
||||||
|
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ connectionTagLabel(topic.tagSlug || topic) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer support session link -->
|
<!-- Peer support session link -->
|
||||||
<a
|
<a
|
||||||
v-if="
|
v-if="showPeerSupport(member)"
|
||||||
member.peerSupport?.enabled && member.peerSupport?.slackUsername
|
|
||||||
"
|
|
||||||
href="#"
|
href="#"
|
||||||
class="mc-session"
|
class="mc-session"
|
||||||
@click.prevent="openSlackDM(member)"
|
@click.prevent="openSlackDM(member)"
|
||||||
|
|
@ -270,16 +275,27 @@ const { render: renderMarkdown } = useMarkdown();
|
||||||
// State
|
// State
|
||||||
const members = ref([]);
|
const members = ref([]);
|
||||||
const totalCount = ref(0);
|
const totalCount = ref(0);
|
||||||
const availableSkills = ref([]);
|
|
||||||
const availableTopics = ref([]);
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const selectedCircle = ref("all");
|
const selectedCircle = ref("all");
|
||||||
const peerSupportFilter = ref("all");
|
const peerSupportFilter = ref("all");
|
||||||
const selectedSkills = ref([]);
|
const selectedCraftTags = ref([]);
|
||||||
const selectedTopics = ref([]);
|
const selectedConnectionTags = ref([]);
|
||||||
const showAllSkills = ref(false);
|
const showAllCraftTags = ref(false);
|
||||||
const showAllTopics = ref(false);
|
const showAllConnectionTags = ref(false);
|
||||||
|
|
||||||
|
// Tag options from API
|
||||||
|
const craftTagOptions = ref([]);
|
||||||
|
const connectionTagOptions = ref([]);
|
||||||
|
|
||||||
|
// State display text mapping
|
||||||
|
const stateLabels = {
|
||||||
|
help: "Can help",
|
||||||
|
interested: "Interested",
|
||||||
|
seeking: "Need help",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
||||||
|
|
||||||
// Circle options
|
// Circle options
|
||||||
const circleOptions = [
|
const circleOptions = [
|
||||||
|
|
@ -295,19 +311,55 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
practitioner: "Practitioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Peer support filter options
|
// Tag slug-to-label lookups
|
||||||
const peerSupportOptions = [
|
const craftTagLabel = (slug) => {
|
||||||
{ label: "All Members", value: "all" },
|
const found = craftTagOptions.value.find((t) => t.slug === slug);
|
||||||
{ label: "Offering Peer Support", value: "true" },
|
return found ? found.label : slug;
|
||||||
];
|
};
|
||||||
|
|
||||||
|
const connectionTagLabel = (slug) => {
|
||||||
|
const found = connectionTagOptions.value.find((t) => t.slug === slug);
|
||||||
|
return found ? found.label : slug;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get craft tags for a member (new field, falling back to offering.tags)
|
||||||
|
const getMemberCraftTags = (member) => {
|
||||||
|
if (member.craftTags && member.craftTags.length > 0) {
|
||||||
|
return member.craftTags;
|
||||||
|
}
|
||||||
|
return member.offering?.tags || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get connection topics for a member (new field, falling back to lookingFor.tags)
|
||||||
|
const getMemberConnectionTopics = (member) => {
|
||||||
|
if (
|
||||||
|
member.communityConnections?.topics &&
|
||||||
|
member.communityConnections.topics.length > 0
|
||||||
|
) {
|
||||||
|
return member.communityConnections.topics;
|
||||||
|
}
|
||||||
|
// Fallback: wrap old lookingFor.tags as plain strings
|
||||||
|
if (member.lookingFor?.tags && member.lookingFor.tags.length > 0) {
|
||||||
|
return member.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show peer support link (check both old and new fields)
|
||||||
|
const showPeerSupport = (member) => {
|
||||||
|
if (member.communityConnections?.offerPeerSupport) return true;
|
||||||
|
if (member.peerSupport?.enabled && member.peerSupport?.slackUsername)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Computed: has active filters
|
// Computed: has active filters
|
||||||
const hasActiveFilters = computed(() => {
|
const hasActiveFilters = computed(() => {
|
||||||
return (
|
return (
|
||||||
(selectedCircle.value && selectedCircle.value !== "all") ||
|
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||||
selectedSkills.value.length > 0 ||
|
selectedCraftTags.value.length > 0 ||
|
||||||
selectedTopics.value.length > 0
|
selectedConnectionTags.value.length > 0
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -333,28 +385,51 @@ const loadMembers = async () => {
|
||||||
params.circle = selectedCircle.value;
|
params.circle = selectedCircle.value;
|
||||||
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
||||||
params.peerSupport = peerSupportFilter.value;
|
params.peerSupport = peerSupportFilter.value;
|
||||||
if (selectedSkills.value.length > 0)
|
if (selectedCraftTags.value.length === 1)
|
||||||
params.skills = selectedSkills.value.join(",");
|
params.craftTag = selectedCraftTags.value[0];
|
||||||
if (selectedTopics.value.length > 0)
|
if (selectedConnectionTags.value.length === 1)
|
||||||
params.topics = selectedTopics.value.join(",");
|
params.connectionTag = selectedConnectionTags.value[0];
|
||||||
|
|
||||||
const data = await $fetch("/api/members/directory", { params });
|
const data = await $fetch("/api/members/directory", { params });
|
||||||
|
|
||||||
members.value = data.members || [];
|
members.value = data.members || [];
|
||||||
totalCount.value = data.totalCount || 0;
|
totalCount.value = data.totalCount || 0;
|
||||||
availableSkills.value = data.filters?.availableSkills || [];
|
|
||||||
availableTopics.value = data.filters?.availableTopics || [];
|
// Update tag options from API response (only on initial load or if empty)
|
||||||
|
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
|
||||||
|
craftTagOptions.value = data.filters.craftTags;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.filters?.cooperativeTags &&
|
||||||
|
connectionTagOptions.value.length === 0
|
||||||
|
) {
|
||||||
|
connectionTagOptions.value = data.filters.cooperativeTags;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load members:", error);
|
console.error("Failed to load members:", error);
|
||||||
members.value = [];
|
members.value = [];
|
||||||
totalCount.value = 0;
|
totalCount.value = 0;
|
||||||
availableSkills.value = [];
|
|
||||||
availableTopics.value = [];
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch tag options from API on mount
|
||||||
|
const loadTagOptions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await $fetch("/api/tags");
|
||||||
|
const tags = data.tags || [];
|
||||||
|
craftTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === "craft")
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
|
connectionTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === "cooperative")
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tags:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle peer support checkbox
|
// Toggle peer support checkbox
|
||||||
const togglePeerSupport = (e) => {
|
const togglePeerSupport = (e) => {
|
||||||
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||||
|
|
@ -370,24 +445,24 @@ const debouncedSearch = () => {
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle skill filter
|
// Toggle craft tag filter
|
||||||
const toggleSkill = (skill) => {
|
const toggleCraftTag = (slug) => {
|
||||||
const index = selectedSkills.value.indexOf(skill);
|
const index = selectedCraftTags.value.indexOf(slug);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
selectedSkills.value.splice(index, 1);
|
selectedCraftTags.value.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
selectedSkills.value.push(skill);
|
selectedCraftTags.value = [slug]; // single-select for API query param
|
||||||
}
|
}
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle topic filter
|
// Toggle connection tag filter
|
||||||
const toggleTopic = (topic) => {
|
const toggleConnectionTag = (slug) => {
|
||||||
const index = selectedTopics.value.indexOf(topic);
|
const index = selectedConnectionTags.value.indexOf(slug);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
selectedTopics.value.splice(index, 1);
|
selectedConnectionTags.value.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
selectedTopics.value.push(topic);
|
selectedConnectionTags.value = [slug]; // single-select for API query param
|
||||||
}
|
}
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
@ -407,14 +482,17 @@ const clearAllFilters = () => {
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
selectedCircle.value = "all";
|
selectedCircle.value = "all";
|
||||||
peerSupportFilter.value = "all";
|
peerSupportFilter.value = "all";
|
||||||
selectedSkills.value = [];
|
selectedCraftTags.value = [];
|
||||||
selectedTopics.value = [];
|
selectedConnectionTags.value = [];
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slack DM functionality
|
// Slack DM functionality
|
||||||
const openSlackDM = async (member) => {
|
const openSlackDM = async (member) => {
|
||||||
const username = member.peerSupport?.slackUsername || member.name;
|
const username =
|
||||||
|
member.communityConnections?.slackHandle ||
|
||||||
|
member.peerSupport?.slackUsername ||
|
||||||
|
member.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(username);
|
await navigator.clipboard.writeText(username);
|
||||||
|
|
@ -429,12 +507,13 @@ const openSlackDM = async (member) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load on mount and handle query params
|
// Load on mount and handle query params
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
if (route.query.peerSupport === "true") {
|
if (route.query.peerSupport === "true") {
|
||||||
peerSupportFilter.value = "true";
|
peerSupportFilter.value = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadTagOptions();
|
||||||
loadMembers();
|
loadMembers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -756,10 +835,28 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-looking {
|
.mc-looking {
|
||||||
font-size: 11px;
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-topic {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-style: italic;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-session {
|
.mc-session {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||||
status: "active",
|
status: "active",
|
||||||
})
|
})
|
||||||
.select(
|
.select(
|
||||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
|
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
||||||
)
|
)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
|
@ -70,6 +70,21 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("offering")) filtered.offering = member.offering;
|
if (isVisible("offering")) filtered.offering = member.offering;
|
||||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||||
|
|
||||||
|
// Craft tags
|
||||||
|
if (isVisible("craftTags")) {
|
||||||
|
filtered.craftTags = member.craftTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community connections (expose only public-safe fields)
|
||||||
|
if (isVisible("communityConnections")) {
|
||||||
|
filtered.communityConnections = {
|
||||||
|
topics: member.communityConnections?.topics,
|
||||||
|
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||||
|
availability: member.communityConnections?.availability,
|
||||||
|
details: member.communityConnections?.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Peer support: expose only fields needed for matching/contact UX
|
// Peer support: expose only fields needed for matching/contact UX
|
||||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||||
if (member.peerSupport?.enabled) {
|
if (member.peerSupport?.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
|
import Tag from "../../models/tag.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
|
||||||
const tags = query.tags ? query.tags.split(",") : [];
|
const tags = query.tags ? query.tags.split(",") : [];
|
||||||
const peerSupport = query.peerSupport || "";
|
const peerSupport = query.peerSupport || "";
|
||||||
const topics = query.topics ? query.topics.split(",") : [];
|
const topics = query.topics ? query.topics.split(",") : [];
|
||||||
|
const craftTag = query.craftTag || "";
|
||||||
|
const connectionTag = query.connectionTag || "";
|
||||||
|
|
||||||
// Build query
|
// Build query
|
||||||
const dbQuery = {
|
const dbQuery = {
|
||||||
|
|
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
|
||||||
dbQuery.circle = circle;
|
dbQuery.circle = circle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by peer support availability
|
// Collect $and conditions for combining multiple filters
|
||||||
|
const andConditions = [];
|
||||||
|
|
||||||
|
// Filter by peer support availability (check both old and new fields)
|
||||||
if (peerSupport === "true") {
|
if (peerSupport === "true") {
|
||||||
dbQuery["peerSupport.enabled"] = true;
|
andConditions.push({
|
||||||
|
$or: [
|
||||||
|
{ "peerSupport.enabled": true },
|
||||||
|
{ "communityConnections.offerPeerSupport": true },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search by name or bio
|
// Search by name or bio
|
||||||
if (search) {
|
if (search) {
|
||||||
// Escape special regex characters to prevent ReDoS
|
// Escape special regex characters to prevent ReDoS
|
||||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
dbQuery.$or = [
|
andConditions.push({
|
||||||
|
$or: [
|
||||||
{ name: { $regex: escaped, $options: "i" } },
|
{ name: { $regex: escaped, $options: "i" } },
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
{ bio: { $regex: escaped, $options: "i" } },
|
||||||
];
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by tags (search in offering.tags or lookingFor.tags)
|
// Filter by tags (search in offering.tags or lookingFor.tags)
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
dbQuery.$or = [
|
andConditions.push({
|
||||||
{ "offering.tags": { $in: tags } },
|
|
||||||
{ "lookingFor.tags": { $in: tags } },
|
|
||||||
];
|
|
||||||
// If search is also present, combine with AND
|
|
||||||
if (search) {
|
|
||||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
dbQuery.$and = [
|
|
||||||
{
|
|
||||||
$or: [
|
|
||||||
{ name: { $regex: escaped, $options: "i" } },
|
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$or: [
|
$or: [
|
||||||
{ "offering.tags": { $in: tags } },
|
{ "offering.tags": { $in: tags } },
|
||||||
{ "lookingFor.tags": { $in: tags } },
|
{ "lookingFor.tags": { $in: tags } },
|
||||||
],
|
],
|
||||||
},
|
});
|
||||||
];
|
|
||||||
delete dbQuery.$or;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by peer support topics
|
// Filter by peer support topics
|
||||||
|
|
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
|
||||||
dbQuery["peerSupport.topics"] = { $in: topics };
|
dbQuery["peerSupport.topics"] = { $in: topics };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by craft tag
|
||||||
|
if (craftTag) {
|
||||||
|
dbQuery.craftTags = craftTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by connection tag
|
||||||
|
if (connectionTag) {
|
||||||
|
dbQuery["communityConnections.topics.tagSlug"] = connectionTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply combined $and conditions
|
||||||
|
if (andConditions.length > 0) {
|
||||||
|
dbQuery.$and = andConditions;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const members = await Member.find(dbQuery)
|
const members = await Member.find(dbQuery)
|
||||||
.select(
|
.select(
|
||||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
|
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
||||||
)
|
)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("offering")) filtered.offering = member.offering;
|
if (isVisible("offering")) filtered.offering = member.offering;
|
||||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||||
|
|
||||||
|
// Craft tags (with fallback to offering.tags for backward compat)
|
||||||
|
if (isVisible("craftTags")) {
|
||||||
|
filtered.craftTags = member.craftTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community connections (expose only public-safe fields)
|
||||||
|
if (isVisible("communityConnections")) {
|
||||||
|
filtered.communityConnections = {
|
||||||
|
topics: member.communityConnections?.topics,
|
||||||
|
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||||
|
availability: member.communityConnections?.availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Peer support: expose only fields needed for matching/contact UX
|
// Peer support: expose only fields needed for matching/contact UX
|
||||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||||
if (member.peerSupport?.enabled) {
|
if (member.peerSupport?.enabled) {
|
||||||
|
|
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
|
||||||
return filtered;
|
return filtered;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get unique tags for filter options (from both offering and lookingFor)
|
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
|
||||||
const allTags = members
|
const allTags = members
|
||||||
.flatMap((m) => [
|
.flatMap((m) => [
|
||||||
...(m.offering?.tags || []),
|
...(m.offering?.tags || []),
|
||||||
|
|
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
|
||||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
|
// Fetch predefined tags from Tag model for filter bars
|
||||||
|
const [craftTags, cooperativeTags] = await Promise.all([
|
||||||
|
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
|
||||||
|
Tag.find({ pool: "cooperative", active: true }).sort({ label: 1 }).lean(),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: filteredMembers,
|
members: filteredMembers,
|
||||||
totalCount: filteredMembers.length,
|
totalCount: filteredMembers.length,
|
||||||
filters: {
|
filters: {
|
||||||
availableSkills: allTags,
|
availableSkills: allTags,
|
||||||
availableTopics: allTopics,
|
availableTopics: allTopics,
|
||||||
|
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
|
||||||
|
cooperativeTags: cooperativeTags.map((t) => ({
|
||||||
|
slug: t.slug,
|
||||||
|
label: t.label,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue