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:
Jennie Robinson Faber 2026-04-05 16:40:10 +01:00
parent bd07172093
commit 896de2e7fd
5 changed files with 367 additions and 138 deletions

View file

@ -60,40 +60,42 @@
</p>
</div>
<!-- Offering Section -->
<div
v-if="member.offering?.tags?.length || member.offering?.text"
class="profile-section"
>
<div class="section-label">Offering</div>
<div v-if="member.offering.tags?.length" class="tag-list">
<!-- What I Do (craft tags, falling back to offering) -->
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
<div class="section-label">What I Do</div>
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
<span
v-for="tag in member.offering.tags"
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tag }}</span
>{{ tagLabel('craft', tag) }}</span
>
</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 }}
</p>
</div>
<!-- Looking For Section -->
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
<div
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text"
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-section"
>
<div class="section-label">Looking for</div>
<div v-if="member.lookingFor.tags?.length" class="tag-list">
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<span
v-for="tag in member.lookingFor.tags"
:key="tag"
class="tag-pill"
>{{ tag }}</span
v-for="topic in connectionTopicsDisplay"
:key="topic.tagSlug || topic"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
</span>
</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 }}
</p>
</div>
@ -150,10 +152,10 @@
</div>
</div>
<!-- Peer Support Section -->
<div v-if="member.peerSupport?.enabled" class="profile-section">
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
<div v-if="showPeerSupport" class="profile-section">
<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>
<div class="tag-list">
<span
@ -164,7 +166,7 @@
>
</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>
<div class="tag-list">
<span
@ -175,8 +177,8 @@
>
</div>
</div>
<p v-if="member.peerSupport.availability" class="profile-detail">
{{ member.peerSupport.availability }}
<p v-if="peerAvailability" class="profile-detail">
{{ peerAvailability }}
</p>
</div>
@ -233,6 +235,15 @@ const circleLabels = {
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) => {
if (!name) return "?";
return name
@ -246,6 +257,11 @@ const getInitials = (name) => {
// Fetch member data no await so the component renders immediately (no Suspense)
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
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
params: { limit: 5 },
@ -267,6 +283,56 @@ const formatRelativeDate = (date) => {
}
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", () => "");
watch(
member,
@ -442,7 +508,8 @@ useHead({
}
.offering-text,
.looking-text {
.looking-text,
.connection-details {
margin-top: 8px;
}
@ -462,6 +529,19 @@ useHead({
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 {
display: flex;

View file

@ -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">&times;</button>
</span>
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
{{ skill }}
<button type="button" @click="toggleSkill(skill)">&times;</button>
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
{{ craftTagLabel(slug) }}
<button type="button" @click="toggleCraftTag(slug)">&times;</button>
</span>
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
{{ topic }}
<button type="button" @click="toggleTopic(topic)">&times;</button>
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
{{ connectionTagLabel(slug) }}
<button type="button" @click="toggleConnectionTag(slug)">&times;</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 {