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
|
|
@ -41,64 +41,64 @@
|
|||
>
|
||||
</div>
|
||||
|
||||
<!-- Skills Filter -->
|
||||
<!-- Craft Tags Filter -->
|
||||
<div
|
||||
v-if="availableSkills && availableSkills.length > 0"
|
||||
v-if="craftTagOptions.length > 0"
|
||||
class="skills-bar"
|
||||
>
|
||||
<span class="tag-label">Skills:</span>
|
||||
<span class="tag-label">Craft:</span>
|
||||
<button
|
||||
v-for="skill in (availableSkills || []).slice(
|
||||
v-for="tag in craftTagOptions.slice(
|
||||
0,
|
||||
showAllSkills ? undefined : 10,
|
||||
showAllCraftTags ? undefined : 10,
|
||||
)"
|
||||
:key="skill"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="skill-tag"
|
||||
:class="{ active: selectedSkills.includes(skill) }"
|
||||
@click="toggleSkill(skill)"
|
||||
:class="{ active: selectedCraftTags.includes(tag.slug) }"
|
||||
@click="toggleCraftTag(tag.slug)"
|
||||
>
|
||||
{{ skill }}
|
||||
{{ tag.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="availableSkills && availableSkills.length > 10"
|
||||
v-if="craftTagOptions.length > 10"
|
||||
type="button"
|
||||
class="more-btn"
|
||||
@click="showAllSkills = !showAllSkills"
|
||||
@click="showAllCraftTags = !showAllCraftTags"
|
||||
>
|
||||
{{
|
||||
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
|
||||
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Topics Filter -->
|
||||
<!-- Connection Tags Filter -->
|
||||
<div
|
||||
v-if="availableTopics && availableTopics.length > 0"
|
||||
v-if="connectionTagOptions.length > 0"
|
||||
class="skills-bar"
|
||||
>
|
||||
<span class="tag-label">Topics:</span>
|
||||
<button
|
||||
v-for="topic in (availableTopics || []).slice(
|
||||
v-for="tag in connectionTagOptions.slice(
|
||||
0,
|
||||
showAllTopics ? undefined : 10,
|
||||
showAllConnectionTags ? undefined : 10,
|
||||
)"
|
||||
:key="topic"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="skill-tag"
|
||||
:class="{ active: selectedTopics.includes(topic) }"
|
||||
@click="toggleTopic(topic)"
|
||||
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
|
||||
@click="toggleConnectionTag(tag.slug)"
|
||||
>
|
||||
{{ topic }}
|
||||
{{ tag.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="availableTopics && availableTopics.length > 10"
|
||||
v-if="connectionTagOptions.length > 10"
|
||||
type="button"
|
||||
class="more-btn"
|
||||
@click="showAllTopics = !showAllTopics"
|
||||
@click="showAllConnectionTags = !showAllConnectionTags"
|
||||
>
|
||||
{{
|
||||
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
|
||||
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -117,16 +117,16 @@
|
|||
Offering Peer Support
|
||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||
</span>
|
||||
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
|
||||
{{ skill }}
|
||||
<button type="button" @click="toggleSkill(skill)">×</button>
|
||||
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
|
||||
{{ craftTagLabel(slug) }}
|
||||
<button type="button" @click="toggleCraftTag(slug)">×</button>
|
||||
</span>
|
||||
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
|
||||
{{ topic }}
|
||||
<button type="button" @click="toggleTopic(topic)">×</button>
|
||||
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
|
||||
{{ connectionTagLabel(slug) }}
|
||||
<button type="button" @click="toggleConnectionTag(slug)">×</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
||||
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
|
||||
type="button"
|
||||
class="clear-all-btn"
|
||||
@click="clearAllFilters"
|
||||
|
|
@ -186,33 +186,38 @@
|
|||
}}
|
||||
</div>
|
||||
|
||||
<!-- Skills tags -->
|
||||
<!-- Craft tags (fall back to offering.tags) -->
|
||||
<div
|
||||
v-if="member.offering?.tags && member.offering.tags.length > 0"
|
||||
v-if="getMemberCraftTags(member).length > 0"
|
||||
class="mc-tags"
|
||||
>
|
||||
<span class="tag-label">Skills:</span>
|
||||
<span class="tag-label">Craft:</span>
|
||||
<span
|
||||
v-for="tag in member.offering.tags"
|
||||
v-for="tag in getMemberCraftTags(member)"
|
||||
:key="tag"
|
||||
class="skill-tag"
|
||||
>{{ tag }}</span
|
||||
>{{ craftTagLabel(tag) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
<!-- Community connections topics (fall back to lookingFor.tags) -->
|
||||
<div
|
||||
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
||||
v-if="getMemberConnectionTopics(member).length > 0"
|
||||
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>
|
||||
|
||||
<!-- Peer support session link -->
|
||||
<a
|
||||
v-if="
|
||||
member.peerSupport?.enabled && member.peerSupport?.slackUsername
|
||||
"
|
||||
v-if="showPeerSupport(member)"
|
||||
href="#"
|
||||
class="mc-session"
|
||||
@click.prevent="openSlackDM(member)"
|
||||
|
|
@ -270,16 +275,27 @@ const { render: renderMarkdown } = useMarkdown();
|
|||
// State
|
||||
const members = ref([]);
|
||||
const totalCount = ref(0);
|
||||
const availableSkills = ref([]);
|
||||
const availableTopics = ref([]);
|
||||
const loading = ref(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);
|
||||
const selectedCraftTags = ref([]);
|
||||
const selectedConnectionTags = ref([]);
|
||||
const showAllCraftTags = 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
|
||||
const circleOptions = [
|
||||
|
|
@ -295,19 +311,55 @@ const circleLabels = {
|
|||
practitioner: "Practitioner",
|
||||
};
|
||||
|
||||
// Peer support filter options
|
||||
const peerSupportOptions = [
|
||||
{ label: "All Members", value: "all" },
|
||||
{ label: "Offering Peer Support", value: "true" },
|
||||
];
|
||||
// Tag slug-to-label lookups
|
||||
const craftTagLabel = (slug) => {
|
||||
const found = craftTagOptions.value.find((t) => t.slug === slug);
|
||||
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
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||
selectedSkills.value.length > 0 ||
|
||||
selectedTopics.value.length > 0
|
||||
selectedCraftTags.value.length > 0 ||
|
||||
selectedConnectionTags.value.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -333,28 +385,51 @@ const loadMembers = async () => {
|
|||
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(",");
|
||||
if (selectedCraftTags.value.length === 1)
|
||||
params.craftTag = selectedCraftTags.value[0];
|
||||
if (selectedConnectionTags.value.length === 1)
|
||||
params.connectionTag = selectedConnectionTags.value[0];
|
||||
|
||||
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 || [];
|
||||
|
||||
// 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) {
|
||||
console.error("Failed to load members:", error);
|
||||
members.value = [];
|
||||
totalCount.value = 0;
|
||||
availableSkills.value = [];
|
||||
availableTopics.value = [];
|
||||
} finally {
|
||||
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
|
||||
const togglePeerSupport = (e) => {
|
||||
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||
|
|
@ -370,24 +445,24 @@ const debouncedSearch = () => {
|
|||
}, 300);
|
||||
};
|
||||
|
||||
// Toggle skill filter
|
||||
const toggleSkill = (skill) => {
|
||||
const index = selectedSkills.value.indexOf(skill);
|
||||
// Toggle craft tag filter
|
||||
const toggleCraftTag = (slug) => {
|
||||
const index = selectedCraftTags.value.indexOf(slug);
|
||||
if (index > -1) {
|
||||
selectedSkills.value.splice(index, 1);
|
||||
selectedCraftTags.value.splice(index, 1);
|
||||
} else {
|
||||
selectedSkills.value.push(skill);
|
||||
selectedCraftTags.value = [slug]; // single-select for API query param
|
||||
}
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
// Toggle topic filter
|
||||
const toggleTopic = (topic) => {
|
||||
const index = selectedTopics.value.indexOf(topic);
|
||||
// Toggle connection tag filter
|
||||
const toggleConnectionTag = (slug) => {
|
||||
const index = selectedConnectionTags.value.indexOf(slug);
|
||||
if (index > -1) {
|
||||
selectedTopics.value.splice(index, 1);
|
||||
selectedConnectionTags.value.splice(index, 1);
|
||||
} else {
|
||||
selectedTopics.value.push(topic);
|
||||
selectedConnectionTags.value = [slug]; // single-select for API query param
|
||||
}
|
||||
loadMembers();
|
||||
};
|
||||
|
|
@ -407,14 +482,17 @@ const clearAllFilters = () => {
|
|||
searchQuery.value = "";
|
||||
selectedCircle.value = "all";
|
||||
peerSupportFilter.value = "all";
|
||||
selectedSkills.value = [];
|
||||
selectedTopics.value = [];
|
||||
selectedCraftTags.value = [];
|
||||
selectedConnectionTags.value = [];
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
// Slack DM functionality
|
||||
const openSlackDM = async (member) => {
|
||||
const username = member.peerSupport?.slackUsername || member.name;
|
||||
const username =
|
||||
member.communityConnections?.slackHandle ||
|
||||
member.peerSupport?.slackUsername ||
|
||||
member.name;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(username);
|
||||
|
|
@ -429,12 +507,13 @@ const openSlackDM = async (member) => {
|
|||
};
|
||||
|
||||
// Load on mount and handle query params
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
const route = useRoute();
|
||||
if (route.query.peerSupport === "true") {
|
||||
peerSupportFilter.value = "true";
|
||||
}
|
||||
|
||||
await loadTagOptions();
|
||||
loadMembers();
|
||||
});
|
||||
|
||||
|
|
@ -756,10 +835,28 @@ useHead({
|
|||
}
|
||||
|
||||
.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);
|
||||
font-style: italic;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mc-session {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue