Merge feature/community-connections into main

Adds Community Connections system: predefined tags with engagement states,
suggested connections page, and member discovery based on shared interests.
This commit is contained in:
Jennie Robinson Faber 2026-04-05 17:05:58 +01:00
commit 689548e389
33 changed files with 2743 additions and 407 deletions

View file

@ -34,8 +34,13 @@
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>{{ item.label }}</NuxtLink
>
{{ item.label }}
<span
v-if="item.path === '/connections' && pendingCount > 0"
class="nav-badge"
>{{ pendingCount }}</span>
</NuxtLink>
</li>
</ul>
</template>
@ -129,7 +134,21 @@ const emit = defineEmits(["navigate"]);
const route = useRoute();
const { isAuthenticated, logout } = useAuth();
const { getPendingCount } = useConnections();
const isDev = import.meta.dev;
const pendingCount = ref(0);
// Fetch pending connection count for authenticated users
onMounted(async () => {
if (isAuthenticated.value) {
try {
const data = await getPendingCount();
pendingCount.value = data.count || 0;
} catch {
// Silently ignore badge is non-critical
}
}
});
const handleNavigate = () => {
if (props.isMobile) {
@ -173,7 +192,8 @@ const youItems = [
const exploreItems = [
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Wiki", path: "/wiki" },
{ label: "Connections", path: "/connections" },
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
{ label: "About", path: "/about" },
];
</script>
@ -278,4 +298,19 @@ const exploreItems = [
.sidebar-meta a {
color: var(--candle-dim);
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
margin-left: 6px;
font-size: 10px;
line-height: 1;
color: var(--bg);
background: var(--candle);
border-radius: 0;
}
</style>

View file

@ -0,0 +1,149 @@
<template>
<div class="coop-tag-selector">
<div
v-for="tag in tags"
:key="tag.slug"
class="coop-row"
>
<span class="tag-label">{{ tag.label }}</span>
<div class="segmented">
<span
v-for="opt in options"
:key="opt.value"
class="seg-option"
:class="{ on: getState(tag.slug) === opt.value }"
@click="toggleState(tag.slug, opt.value)"
>{{ opt.label }}</span>
</div>
</div>
<div class="suggest-link">
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue", "suggest"]);
const options = [
{ label: "Can help", value: "help" },
{ label: "Interested", value: "interested" },
{ label: "Need help", value: "seeking" },
];
function getState(slug) {
const entry = props.modelValue.find((e) => e.tagSlug === slug);
return entry ? entry.state : null;
}
function toggleState(slug, value) {
const current = [...props.modelValue];
const idx = current.findIndex((e) => e.tagSlug === slug);
const existingState = idx !== -1 ? current[idx].state : null;
if (existingState === value) {
// clicking active state deselects it
if (idx !== -1) current.splice(idx, 1);
} else if (idx !== -1) {
current[idx] = { tagSlug: slug, state: value };
} else {
current.push({ tagSlug: slug, state: value });
}
emit("update:modelValue", current);
}
</script>
<style scoped>
.coop-tag-selector {
display: flex;
flex-direction: column;
gap: 0;
}
.coop-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
border-bottom: 1px dashed var(--border);
}
.coop-row:first-child {
border-top: 1px dashed var(--border);
}
.tag-label {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--text-dim);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.segmented {
display: inline-flex;
gap: 0;
flex-shrink: 0;
}
.seg-option {
padding: 2px 7px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
color: var(--text-faint);
font-size: 9px;
font-family: "Commit Mono", monospace;
letter-spacing: 0.02em;
cursor: pointer;
transition: all 0.12s;
user-select: none;
white-space: nowrap;
position: relative;
}
.seg-option + .seg-option {
margin-left: -1px;
}
.seg-option:hover {
color: var(--text-dim);
}
.seg-option.on {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
z-index: 1;
}
.suggest-link {
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-faint);
margin-top: 8px;
}
.suggest-link span {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.suggest-link span:hover {
color: var(--text-dim);
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<div class="craft-tag-selector">
<div class="pill-grid">
<button
v-for="tag in tags"
:key="tag.slug"
type="button"
class="pill"
:class="{ selected: modelValue.includes(tag.slug) }"
@click="toggle(tag.slug)"
>{{ tag.label }}</button>
</div>
<div class="suggest-link">
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue", "suggest"]);
function toggle(slug) {
const current = [...props.modelValue];
const idx = current.indexOf(slug);
if (idx === -1) {
emit("update:modelValue", [...current, slug]);
} else {
current.splice(idx, 1);
emit("update:modelValue", current);
}
}
</script>
<style scoped>
.craft-tag-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-faint);
font-size: 11px;
font-family: "Commit Mono", monospace;
cursor: pointer;
user-select: none;
transition: all 0.12s;
white-space: nowrap;
}
.pill:hover {
color: var(--text-dim);
border-color: var(--border-d);
}
.pill.selected {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
}
.suggest-link {
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-faint);
margin-top: 2px;
}
.suggest-link span {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.suggest-link span:hover {
color: var(--text-dim);
}
</style>

View file

@ -0,0 +1,106 @@
<template>
<UModal v-model:open="open" :title="`Suggest a ${pool} tag`" :dismissible="true">
<template #body>
<div class="suggest-modal-body">
<div v-if="success" class="success-msg">
Thanks! We'll review your suggestion.
</div>
<form v-else @submit.prevent="submit" class="suggest-form">
<div class="field">
<label>Tag name</label>
<input
v-model="tagName"
type="text"
placeholder="e.g., Game Narrative Design"
required
:disabled="submitting"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting || !tagName.trim()">
{{ submitting ? "Sending..." : "Submit suggestion" }}
</button>
<button type="button" class="btn" @click="open = false">Cancel</button>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
</form>
</div>
</template>
</UModal>
</template>
<script setup>
const props = defineProps({
pool: { type: String, default: "" },
});
const emit = defineEmits(["close"]);
const open = defineModel("open", { default: false });
const tagName = ref("");
const submitting = ref(false);
const success = ref(false);
const error = ref(null);
watch(open, (val) => {
if (!val) {
// reset state when closed
tagName.value = "";
submitting.value = false;
success.value = false;
error.value = null;
}
});
async function submit() {
if (!tagName.value.trim()) return;
submitting.value = true;
error.value = null;
try {
await $fetch("/api/tags/suggest", {
method: "POST",
body: { label: tagName.value.trim(), pool: props.pool },
});
success.value = true;
} catch (e) {
error.value = e?.data?.message || "Something went wrong. Please try again.";
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.suggest-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggest-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-actions {
display: flex;
gap: 8px;
align-items: center;
}
.success-msg {
font-size: 12px;
font-family: "Commit Mono", monospace;
color: var(--green);
padding: 8px 0;
}
.error-msg {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--ember);
margin: 0;
}
</style>

View file

@ -0,0 +1,32 @@
export const useConnections = () => {
const getSuggestions = (params = {}) =>
$fetch('/api/connections/suggestions', { params })
const getMyConnections = () =>
$fetch('/api/connections')
const requestConnection = (recipientId) =>
$fetch('/api/connections', { method: 'POST', body: { recipientId } })
const confirmConnection = (id) =>
$fetch(`/api/connections/${id}/confirm`, { method: 'POST' })
const hideConnection = (id) =>
$fetch(`/api/connections/${id}/hide`, { method: 'POST' })
const withdrawConnection = (id) =>
$fetch(`/api/connections/${id}/withdraw`, { method: 'POST' })
const getPendingCount = () =>
$fetch('/api/connections/pending-count')
return {
getSuggestions,
getMyConnections,
requestConnection,
confirmConnection,
hideConnection,
withdrawConnection,
getPendingCount,
}
}

823
app/pages/connections.vue Normal file
View file

@ -0,0 +1,823 @@
<template>
<div class="connections-page">
<PageHeader
title="Connections"
subtitle="Find members who share your cooperative interests"
/>
<ClientOnly>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<p>Loading connections...</p>
</div>
<template v-else>
<!-- Filter Bar -->
<div v-if="tagOptions.length > 0" class="filter-bar">
<select
v-model="filterTag"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All topics</option>
<option v-for="tag in tagOptions" :key="tag.slug" :value="tag.slug">
{{ tag.label }}
</option>
</select>
<select
v-model="filterState"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All states</option>
<option value="help">Can help</option>
<option value="interested">Interested</option>
<option value="seeking">Need help</option>
</select>
</div>
<!-- Suggested Connections -->
<div class="connections-section">
<div class="section-label">Suggested Connections</div>
<div v-if="suggestions.length > 0" class="connection-grid">
<div
v-for="suggestion in suggestions"
:key="suggestion.member._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="suggestion.member.avatar"
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
:alt="suggestion.member.name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${suggestion.member._id}`">
{{ suggestion.member.name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="suggestion.member.circle || 'community'" />
</div>
</div>
</div>
<!-- Craft tags -->
<div
v-if="suggestion.member.craftTags?.length"
class="cc-craft-tags"
>
<span
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
:key="tag"
class="craft-pill"
>{{ craftTagLabel(tag) }}</span>
</div>
<!-- Matching topics with state labels -->
<div class="cc-matches">
<div
v-for="match in suggestion.matchingTags"
:key="match.tagSlug"
class="match-row"
>
<span class="match-tag">{{ cooperativeTagLabel(match.tagSlug) }}</span>
<span class="match-states">
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
<span class="match-sep">&middot;</span>
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
</span>
</div>
</div>
<!-- Actions -->
<div class="cc-actions">
<button
class="btn btn-primary btn-sm"
:disabled="actionLoading === suggestion.member._id"
@click="handleConnect(suggestion.member._id)"
>
Mark as connected
</button>
<button
class="text-action"
:disabled="actionLoading === suggestion.member._id"
@click="handleHide(suggestion.member._id)"
>
Hide
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<p class="empty-title">No suggestions right now</p>
<p class="empty-sub">
Add cooperative topics to your
<NuxtLink to="/member/profile">profile</NuxtLink>
to find members with shared interests
</p>
</div>
</div>
<!-- Your Connections -->
<div class="connections-section">
<div class="section-label">Your Connections</div>
<!-- Pending Incoming -->
<template v-if="pendingIncoming.length > 0">
<div class="subsection-label">Pending Incoming</div>
<div class="connection-grid">
<div
v-for="conn in pendingIncoming"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span class="pending-label">Wants to connect</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
<div class="cc-actions">
<button
class="btn btn-primary btn-sm"
:disabled="actionLoading === conn._id"
@click="handleConfirm(conn._id)"
>
Confirm
</button>
</div>
</div>
</div>
</template>
<!-- Pending Outgoing -->
<template v-if="pendingOutgoing.length > 0">
<div class="subsection-label">Pending Outgoing</div>
<div class="connection-grid">
<div
v-for="conn in pendingOutgoing"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span class="pending-label">Pending</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
<div class="cc-actions">
<button
class="text-action"
:disabled="actionLoading === conn._id"
@click="handleWithdraw(conn._id)"
>
Withdraw
</button>
</div>
</div>
</div>
</template>
<!-- Confirmed -->
<template v-if="confirmed.length > 0">
<div class="subsection-label">Connected</div>
<div class="connection-grid">
<div
v-for="conn in confirmed"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span
v-if="getSlackHandle(conn)"
class="cc-slack"
>@{{ getSlackHandle(conn) }}</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
</div>
</div>
</template>
<!-- No connections at all -->
<div
v-if="!confirmed.length && !pendingIncoming.length && !pendingOutgoing.length && !showHidden"
class="empty-state"
>
<p class="empty-sub">
You don't have any connections yet. Use the suggestions above to get started.
</p>
</div>
</div>
<!-- Show hidden toggle -->
<div v-if="hiddenCount > 0" class="hidden-toggle">
<label class="filter-toggle">
<input
type="checkbox"
v-model="showHidden"
@change="handleShowHiddenToggle"
/>
Show {{ hiddenCount }} hidden suggestion{{ hiddenCount === 1 ? '' : 's' }}
</label>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p>Loading connections...</p>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const { memberData } = useAuth()
const {
getSuggestions,
getMyConnections,
requestConnection,
confirmConnection,
hideConnection,
withdrawConnection,
} = useConnections()
// State
const loading = ref(true)
const actionLoading = ref(null)
const suggestions = ref([])
const confirmed = ref([])
const pendingIncoming = ref([])
const pendingOutgoing = ref([])
const filterTag = ref('')
const filterState = ref('')
const showHidden = ref(false)
const hiddenCount = ref(0)
const tagOptions = ref([])
const craftTagOptions = ref([])
// State display text
const stateLabels = {
help: 'Can help',
interested: 'Interested',
seeking: 'Need help',
}
const stateLabel = (state) => stateLabels[state] || state || ''
// Tag label lookups
const cooperativeTagLabel = (slug) => {
const found = tagOptions.value.find(t => t.slug === slug)
return found ? found.label : slug
}
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find(t => t.slug === slug)
return found ? found.label : slug
}
// Helpers
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
const capitalize = (str) => {
if (!str) return ''
return str.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const getOtherMember = (conn) => {
const myId = memberData.value?._id || memberData.value?.id
if (conn.initiator?._id === myId || conn.initiator?.id === myId) {
return conn.recipient || {}
}
return conn.initiator || {}
}
const getSlackHandle = (conn) => {
const other = getOtherMember(conn)
return other.communityConnections?.slackHandle || null
}
// Data loading
const loadSuggestions = async () => {
try {
const params = {}
if (filterTag.value) params.tag = filterTag.value
if (filterState.value) params.state = filterState.value
if (showHidden.value) params.showHidden = 'true'
const data = await getSuggestions(params)
suggestions.value = data.suggestions || []
} catch (error) {
console.error('Failed to load suggestions:', error)
suggestions.value = []
}
}
const loadConnections = async () => {
try {
const data = await getMyConnections()
confirmed.value = data.confirmed || []
pendingOutgoing.value = data.pendingOutgoing || []
pendingIncoming.value = data.pendingIncoming || []
} catch (error) {
console.error('Failed to load connections:', error)
}
}
const loadTags = async () => {
try {
const data = await $fetch('/api/tags')
const tags = data.tags || []
const cooperativeTags = tags
.filter(t => t.pool === 'cooperative')
.map(t => ({ slug: t.slug, label: t.label }))
tagOptions.value = cooperativeTags
craftTagOptions.value = tags
.filter(t => t.pool === 'craft')
.map(t => ({ slug: t.slug, label: t.label }))
// Filter tag options to only member's selected topics
const myTopicSlugs = (memberData.value?.communityConnections?.topics || [])
.map(t => t.tagSlug)
if (myTopicSlugs.length > 0) {
tagOptions.value = cooperativeTags.filter(t => myTopicSlugs.includes(t.slug))
}
} catch (error) {
console.error('Failed to load tags:', error)
}
}
// Count hidden suggestions (for the toggle label)
const countHidden = async () => {
// We don't have a dedicated hidden-count endpoint, so we track locally
// Hidden count increments when user hides, decrements on show
// This is approximate; it resets on page load
hiddenCount.value = 0
}
// Actions
const handleConnect = async (memberId) => {
actionLoading.value = memberId
try {
await requestConnection(memberId)
// Remove from suggestions, refresh connections
suggestions.value = suggestions.value.filter(s => s.member._id !== memberId)
await loadConnections()
} catch (error) {
console.error('Failed to create connection:', error)
} finally {
actionLoading.value = null
}
}
const handleHide = async (memberId) => {
actionLoading.value = memberId
try {
// The hide endpoint requires a connection ID. For suggestions (no connection yet),
// we create a pending connection first, then immediately hide it.
const result = await requestConnection(memberId)
const connId = result?.connection?._id
if (connId) {
await hideConnection(connId)
}
suggestions.value = suggestions.value.filter(s => s.member._id !== memberId)
hiddenCount.value++
} catch (error) {
console.error('Failed to hide suggestion:', error)
} finally {
actionLoading.value = null
}
}
const handleConfirm = async (connectionId) => {
actionLoading.value = connectionId
try {
await confirmConnection(connectionId)
await loadConnections()
} catch (error) {
console.error('Failed to confirm connection:', error)
} finally {
actionLoading.value = null
}
}
const handleWithdraw = async (connectionId) => {
actionLoading.value = connectionId
try {
await withdrawConnection(connectionId)
await loadConnections()
await loadSuggestions()
} catch (error) {
console.error('Failed to withdraw connection:', error)
} finally {
actionLoading.value = null
}
}
const handleShowHiddenToggle = async () => {
await loadSuggestions()
}
// Init
onMounted(async () => {
loading.value = true
try {
await loadTags()
await Promise.all([
loadSuggestions(),
loadConnections(),
])
countHidden()
} finally {
loading.value = false
}
})
useHead({
title: 'Connections - Ghost Guild',
meta: [
{
name: 'description',
content: 'Find and connect with Ghost Guild members who share your cooperative interests.',
},
],
})
</script>
<style scoped>
/* ---- LOADING ---- */
.loading-state {
padding: 60px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* ---- FILTER BAR ---- */
.filter-bar {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 5px 10px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 26px;
}
.filter-select:focus {
border-color: var(--candle-faint);
}
/* ---- SECTIONS ---- */
.connections-section {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.subsection-label {
font-size: 11px;
color: var(--text-dim);
margin: 16px 0 8px;
}
.subsection-label:first-of-type {
margin-top: 8px;
}
/* ---- CONNECTION GRID ---- */
.connection-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.connection-card {
padding: 16px 20px;
border: 1px dashed var(--border);
margin: -1px 0 0 -1px;
transition: background 0.15s;
}
.connection-card:hover {
background: var(--surface);
}
/* ---- CARD HEADER ---- */
.cc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.cc-avatar {
width: 32px;
height: 32px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
}
.cc-avatar-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.cc-info {
min-width: 0;
}
.cc-name {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.cc-name a {
color: var(--text-bright);
text-decoration: none;
}
.cc-name a:hover {
color: var(--candle);
text-decoration: underline;
}
.cc-meta {
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 1px;
}
.cc-slack {
font-size: 11px;
color: var(--candle-dim);
}
.pending-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--candle-dim);
}
/* ---- CRAFT TAGS (small pills) ---- */
.cc-craft-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
.craft-pill {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
}
/* ---- INLINE TAGS (for confirmed/pending) ---- */
.cc-tags-inline {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
/* ---- MATCH ROWS (suggestion cards) ---- */
.cc-matches {
margin: 8px 0;
}
.match-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 11px;
}
.match-tag {
color: var(--text);
font-weight: 600;
min-width: 0;
}
.match-states {
color: var(--text-faint);
font-size: 10px;
display: flex;
gap: 4px;
align-items: baseline;
flex-wrap: wrap;
}
.match-sep {
color: var(--border);
}
.match-you,
.match-them {
white-space: nowrap;
}
/* ---- ACTIONS ---- */
.cc-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.btn-sm {
padding: 4px 12px;
font-size: 11px;
}
.text-action {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.text-action:hover {
color: var(--text);
text-decoration: underline;
}
.text-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---- EMPTY STATE ---- */
.empty-state {
padding: 32px 0;
text-align: center;
}
.empty-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
color: var(--text-dim);
margin-bottom: 6px;
}
.empty-sub {
font-size: 12px;
color: var(--text-faint);
}
.empty-sub a {
color: var(--candle);
}
/* ---- HIDDEN TOGGLE ---- */
.hidden-toggle {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.connection-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
padding: 14px 20px;
}
.connections-section {
padding: 16px 20px;
}
.connection-card {
padding: 14px 16px;
}
}
</style>

View file

@ -140,45 +140,16 @@
</div>
<PrivacyToggle v-model="formData.bioPrivacy" />
</div>
</div>
<!-- Skills Exchange -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Skills Exchange</div>
<!-- What I Do (craft tags) -->
<div class="field">
<label>What I Can Contribute</label>
<TagInput
v-model="formData.offering.tags"
placeholder="add skill..."
<label>What I Do</label>
<CraftTagSelector
v-model="formData.craftTags"
:tags="craftTags"
@suggest="tagSuggestPool = 'craft'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.offeringPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.offering.text"
rows="3"
placeholder="e.g., I have 10+ years in Unity and love helping new devs."
></textarea>
</div>
<div class="field">
<label>What I'm Looking For</label>
<TagInput
v-model="formData.lookingFor.tags"
placeholder="add topic..."
/>
<PrivacyToggle v-model="formData.lookingForPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.lookingFor.text"
rows="3"
placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."
></textarea>
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
</div>
</div>
@ -206,11 +177,34 @@
<!-- ======== RIGHT COLUMN ======== -->
<div class="profile-col-right">
<div class="profile-col-inset">
<div class="section-label">Peer Support</div>
<div class="section-label">Community Connections</div>
<div class="field">
<label>Topics</label>
<CooperativeTagSelector
v-model="formData.communityConnectionsTopics"
:tags="cooperativeTags"
@suggest="tagSuggestPool = 'cooperative'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.communityConnectionsPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.communityConnectionsDetails"
rows="3"
placeholder="What are you hoping to connect about?"
maxlength="300"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsDetails?.length || 0 }} / 300
</div>
</div>
<div class="toggle-field">
<USwitch
v-model="formData.peerSupportEnabled"
v-model="formData.communityConnectionsOfferPeerSupport"
aria-label="Offer Peer Support"
/>
<div class="toggle-label">
@ -221,47 +215,11 @@
</div>
</div>
<div v-if="formData.peerSupportEnabled" class="peer-panel">
<div class="field">
<label>Skill-Based Topics</label>
<TagInput
v-model="formData.peerSupportSkillTopics"
placeholder="add topic..."
/>
<div v-if="suggestedSkillTopics.length" class="suggested">
Suggested from your offerings:
<a
v-for="tag in suggestedSkillTopics"
:key="tag"
@click="addSuggestedSkillTopic(tag)"
>{{ tag }}</a
>
</div>
</div>
<div class="field">
<label>Conversational Topics</label>
<div class="checkbox-grid">
<label
v-for="topic in availableSupportTopics"
:key="topic"
class="checkbox-item"
:class="{
checked:
formData.peerSupportSupportTopics.includes(topic),
}"
@click.prevent="toggleSupportTopic(topic)"
>
<span class="cb">&#10003;</span>
{{ topic }}
</label>
</div>
</div>
<div v-if="formData.communityConnectionsOfferPeerSupport" class="connections-panel">
<div class="field">
<label>Availability</label>
<textarea
v-model="formData.peerSupportAvailability"
v-model="formData.communityConnectionsAvailability"
rows="3"
placeholder="e.g. Weekday afternoons ET"
></textarea>
@ -270,7 +228,7 @@
<div class="field">
<label>Slack Handle</label>
<input
v-model="formData.peerSupportSlackUsername"
v-model="formData.communityConnectionsSlackHandle"
type="text"
placeholder="@yourslackname"
/>
@ -279,13 +237,13 @@
<div class="field">
<label>Personal Message</label>
<textarea
v-model="formData.peerSupportMessage"
v-model="formData.communityConnectionsPersonalMessage"
rows="3"
maxlength="200"
placeholder="Brief note shown to people requesting time with you"
></textarea>
<div class="char-count">
{{ formData.peerSupportMessage?.length || 0 }} / 200
{{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
</div>
</div>
</div>
@ -324,11 +282,11 @@
<div class="toggle-field">
<USwitch
v-model="formData.notifyPeerRequests"
aria-label="Peer support requests"
v-model="formData.notifyConnectionRequests"
aria-label="Connection requests"
/>
<div class="toggle-label">
Peer support requests
Connection requests
<span class="toggle-sub"
>When someone wants to connect</span
>
@ -360,6 +318,9 @@
</div>
</form>
</div>
<!-- Tag Suggest Modal -->
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
</div>
</template>
@ -389,6 +350,20 @@ const availableGhosts = [
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
];
// Fetch tags and split into pools
const { data: tagsData } = await useFetch("/api/tags");
const craftTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
);
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
);
// Tag suggest modal state
const showTagSuggestModal = ref(false);
const tagSuggestPool = ref("");
// Form state
const formData = reactive({
name: "",
@ -398,16 +373,18 @@ const formData = reactive({
studio: "",
bio: "",
location: "",
offering: { text: "", tags: [] },
lookingFor: { text: "", tags: [] },
showInDirectory: true,
// Peer support
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: "",
peerSupportMessage: "",
peerSupportSlackUsername: "",
// Craft tags
craftTags: [],
craftTagsPrivacy: "members",
// Community connections
communityConnectionsTopics: [],
communityConnectionsPrivacy: "members",
communityConnectionsDetails: "",
communityConnectionsOfferPeerSupport: false,
communityConnectionsAvailability: "",
communityConnectionsSlackHandle: "",
communityConnectionsPersonalMessage: "",
// Privacy
pronounsPrivacy: "members",
timeZonePrivacy: "members",
@ -415,12 +392,10 @@ const formData = reactive({
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
offeringPrivacy: "members",
lookingForPrivacy: "members",
// Notifications
notifyEvents: true,
notifyUpdates: true,
notifyPeerRequests: true,
notifyConnectionRequests: true,
});
const loading = ref(false);
@ -429,47 +404,11 @@ const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Available conversational support topics
const availableSupportTopics = [
"Co-founder relationships",
"Burnout prevention",
"Impostor syndrome",
"Work-life boundaries",
"Conflict resolution",
"General chat & support",
];
// Computed
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
const suggestedSkillTopics = computed(() => {
if (!formData.offering.tags?.length) return [];
return formData.offering.tags.filter(
(t) => !formData.peerSupportSkillTopics?.includes(t),
);
});
// Toggle a support topic in/out of the selection
const toggleSupportTopic = (topic) => {
const idx = formData.peerSupportSupportTopics.indexOf(topic);
if (idx >= 0) {
formData.peerSupportSupportTopics.splice(idx, 1);
} else {
formData.peerSupportSupportTopics.push(topic);
}
};
const addSuggestedSkillTopic = (tag) => {
if (!Array.isArray(formData.peerSupportSkillTopics)) {
formData.peerSupportSkillTopics = [];
}
if (!formData.peerSupportSkillTopics.includes(tag)) {
formData.peerSupportSkillTopics.push(tag);
}
};
// Load member data into form
const loadProfile = () => {
if (memberData.value) {
@ -481,66 +420,21 @@ const loadProfile = () => {
formData.bio = memberData.value.bio || "";
formData.location = memberData.value.location || "";
// Load offering (handle both old string and new object format)
if (typeof memberData.value.offering === "string") {
formData.offering.text = memberData.value.offering;
formData.offering.tags = [];
} else if (memberData.value.offering) {
formData.offering.text = memberData.value.offering?.text || "";
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
? [...memberData.value.offering.tags]
: [];
} else {
formData.offering.text = "";
formData.offering.tags = [];
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === "string") {
formData.lookingFor.text = memberData.value.lookingFor;
formData.lookingFor.tags = [];
} else if (memberData.value.lookingFor) {
formData.lookingFor.text = memberData.value.lookingFor?.text || "";
formData.lookingFor.tags = Array.isArray(
memberData.value.lookingFor?.tags,
)
? [...memberData.value.lookingFor.tags]
: [];
} else {
formData.lookingFor.text = "";
formData.lookingFor.tags = [];
}
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load peer support data
if (memberData.value.peerSupport) {
formData.peerSupportEnabled =
memberData.value.peerSupport.enabled || false;
formData.peerSupportSkillTopics = Array.isArray(
memberData.value.peerSupport.skillTopics,
)
? [...memberData.value.peerSupport.skillTopics]
// Load craft tags
formData.craftTags = Array.isArray(memberData.value.craftTags)
? [...memberData.value.craftTags]
: [];
formData.peerSupportSupportTopics = Array.isArray(
memberData.value.peerSupport.supportTopics,
)
? [...memberData.value.peerSupport.supportTopics]
: [];
formData.peerSupportAvailability =
memberData.value.peerSupport.availability || "";
formData.peerSupportMessage =
memberData.value.peerSupport.personalMessage || "";
formData.peerSupportSlackUsername =
memberData.value.peerSupport.slackUsername || "";
} else {
formData.peerSupportEnabled = false;
formData.peerSupportSkillTopics = [];
formData.peerSupportSupportTopics = [];
formData.peerSupportAvailability = "";
formData.peerSupportMessage = "";
formData.peerSupportSlackUsername = "";
}
// Load community connections (with fallback to old peerSupport fields)
const cc = memberData.value.communityConnections || {};
formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : [];
formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false;
formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || "";
formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || "";
formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || "";
formData.communityConnectionsDetails = cc.details || "";
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
@ -550,14 +444,14 @@ const loadProfile = () => {
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "members";
formData.craftTagsPrivacy = privacy.craftTags || "members";
formData.communityConnectionsPrivacy = privacy.communityConnections || "members";
// Load notification prefs
const notifs = memberData.value.notifications || {};
formData.notifyEvents = notifs.events ?? true;
formData.notifyUpdates = notifs.updates ?? true;
formData.notifyPeerRequests = notifs.peerRequests ?? true;
formData.notifyConnectionRequests = notifs.connectionRequests ?? notifs.peerRequests ?? true;
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
@ -571,29 +465,30 @@ const handleSubmit = async () => {
saveError.value = null;
try {
// Save profile data
// Save profile data (includes craft tags + privacy + notifications)
await $fetch("/api/members/profile", {
method: "PATCH",
body: {
...formData,
craftTags: formData.craftTags,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
connectionRequests: formData.notifyConnectionRequests,
},
},
});
// Save peer support data separately
await $fetch("/api/members/me/peer-support", {
// Save community connections data
await $fetch("/api/members/me/community-connections", {
method: "PATCH",
body: {
enabled: formData.peerSupportEnabled,
skillTopics: formData.peerSupportSkillTopics,
supportTopics: formData.peerSupportSupportTopics,
availability: formData.peerSupportAvailability,
personalMessage: formData.peerSupportMessage,
slackUsername: formData.peerSupportSlackUsername,
topics: formData.communityConnectionsTopics,
offerPeerSupport: formData.communityConnectionsOfferPeerSupport,
availability: formData.communityConnectionsAvailability,
slackHandle: formData.communityConnectionsSlackHandle,
personalMessage: formData.communityConnectionsPersonalMessage,
details: formData.communityConnectionsDetails,
},
});
@ -844,48 +739,8 @@ useHead({
margin-top: 1px;
}
/* ---- CHECKBOX GRID ---- */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 12px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
padding: 2px 0;
user-select: none;
}
.checkbox-item .cb {
width: 13px;
height: 13px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: transparent;
flex-shrink: 0;
}
.checkbox-item.checked .cb {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.checkbox-item:hover {
color: var(--text);
}
/* ---- PEER SUPPORT PANEL ---- */
.peer-panel {
/* ---- CONNECTIONS PANEL ---- */
.connections-panel {
border: 1px dashed var(--border);
padding: 12px 14px;
margin-top: 4px;
@ -893,19 +748,6 @@ useHead({
background: var(--surface);
}
.peer-panel .suggested {
font-size: 10px;
color: var(--text-faint);
margin-top: 4px;
}
.peer-panel .suggested a {
color: var(--candle);
text-decoration: underline;
cursor: pointer;
margin-left: 4px;
}
/* ---- DISABLED BUTTON ---- */
.btn:disabled {
opacity: 0.4;
@ -962,10 +804,6 @@ useHead({
grid-template-columns: 1fr;
}
.checkbox-grid {
grid-template-columns: 1fr;
}
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 16px;

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 {

View file

@ -3,10 +3,10 @@
</template>
<script setup>
// Redirect to members directory with peer support filter
// Redirect to connections page
definePageMeta({
middleware: defineNuxtRouteMiddleware(() => {
return navigateTo("/members?peerSupport=true");
return navigateTo("/connections");
}),
});
</script>

View file

@ -81,6 +81,22 @@ const formatters = {
text: m.subject ? `Email: ${m.subject}` : 'Email sent',
icon: 'i-lucide-mail',
emailBody: m.body || null
}),
community_connections_updated: () => ({
text: 'Updated community connections',
icon: 'i-lucide-users'
}),
connection_requested: (m) => ({
text: `Sent connection request to ${m.memberName || 'a member'}`,
icon: 'i-lucide-user-plus'
}),
connection_confirmed: (m) => ({
text: `Connected with ${m.memberName || 'a member'}`,
icon: 'i-lucide-handshake'
}),
tag_suggested: (m) => ({
text: `Suggested tag: ${m.label || 'unknown'}`,
icon: 'i-lucide-tag'
})
}

113
scripts/seed-tags.js Normal file
View file

@ -0,0 +1,113 @@
/**
* Seed Script: Tags
*
* Upserts craft and cooperative tags by slug (idempotent).
* Safe to run multiple times.
*/
import 'dotenv/config'
import mongoose from 'mongoose'
import Tag from '../server/models/tag.js'
import { connectDB } from '../server/utils/mongoose.js'
// Convert a slug like "qa-and-testing" to "QA and Testing"
// Special-cases common abbreviations.
const ABBREVIATIONS = new Map([
['qa', 'QA'],
['ux', 'UX'],
['ui', 'UI'],
['devops', 'DevOps'],
])
function slugToLabel(slug) {
return slug
.split('-')
.map((word) => ABBREVIATIONS.get(word) ?? word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const CRAFT_SLUGS = [
'game-design',
'programming',
'narrative-design',
'art-and-animation',
'audio-and-music',
'production-management',
'qa-and-testing',
'community-management',
'marketing-and-comms',
'ux-and-ui-design',
'business-development',
'devops-and-tools',
'localization',
'accessibility',
'analytics-and-data',
'education-and-mentoring',
]
const COOPERATIVE_SLUGS = [
'governance',
'finance-and-budgeting',
'legal-structures',
'conflict-resolution',
'consensus-decision-making',
'revenue-sharing',
'cooperative-bylaws',
'member-onboarding',
'democratic-management',
'worker-ownership',
'platform-cooperativism',
'cooperative-marketing',
'shared-resources',
'cooperative-funding',
'community-building',
'equity-and-inclusion',
'cooperative-tech',
'sustainability',
'collective-bargaining',
'inter-coop-collaboration',
]
async function seedTags() {
await connectDB()
const tagDefs = [
...CRAFT_SLUGS.map((slug) => ({ slug, pool: 'craft', label: slugToLabel(slug) })),
...COOPERATIVE_SLUGS.map((slug) => ({ slug, pool: 'cooperative', label: slugToLabel(slug) })),
]
let upserted = 0
let unchanged = 0
for (const { slug, pool, label } of tagDefs) {
const result = await Tag.updateOne(
{ slug },
{ $setOnInsert: { slug, pool, label, active: true, createdAt: new Date() } },
{ upsert: true }
)
if (result.upsertedCount > 0) {
console.log(` + Created [${pool}] ${label} (${slug})`)
upserted++
} else {
unchanged++
}
}
console.log('\n=== Seed Complete ===')
console.log(` Total tags defined: ${tagDefs.length}`)
console.log(` Newly created: ${upserted}`)
console.log(` Already existed: ${unchanged}`)
}
seedTags()
.then(() => {
console.log('\nTag seed completed successfully')
process.exit(0)
})
.catch((err) => {
console.error('\nTag seed failed:', err)
process.exit(1)
})
.finally(() => {
mongoose.connection.close()
})

View file

@ -0,0 +1,54 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
import Member from '../../../models/member.js'
import { requireAuth } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Only the recipient can confirm
if (connection.recipient.toString() !== memberId.toString()) {
throw createError({
statusCode: 403,
statusMessage: 'Only the recipient can confirm a connection'
})
}
if (connection.status !== 'pending') {
throw createError({
statusCode: 400,
statusMessage: 'Connection is not pending'
})
}
connection.status = 'confirmed'
connection.confirmedAt = new Date()
await connection.save()
// Get initiator name for activity log
const initiator = await Member.findById(connection.initiator)
.select('name')
.lean()
logActivity(memberId, 'connection_confirmed', { memberName: initiator?.name || 'Unknown' })
logActivity(connection.initiator, 'connection_confirmed', { memberName: member.name })
return { connection }
})

View file

@ -0,0 +1,48 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
import { requireAuth } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Either party can hide
const isParty =
connection.initiator.toString() === memberId.toString() ||
connection.recipient.toString() === memberId.toString()
if (!isParty) {
throw createError({
statusCode: 403,
statusMessage: 'Not authorized to hide this connection'
})
}
// Add to hiddenBy if not already there
const alreadyHidden = connection.hiddenBy.some(
id => id.toString() === memberId.toString()
)
if (!alreadyHidden) {
connection.hiddenBy.push(memberId)
await connection.save()
}
return { success: true }
})

View file

@ -0,0 +1,43 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
import { requireAuth } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Only the initiator can withdraw
if (connection.initiator.toString() !== memberId.toString()) {
throw createError({
statusCode: 403,
statusMessage: 'Only the initiator can withdraw a connection request'
})
}
if (connection.status !== 'pending') {
throw createError({
statusCode: 400,
statusMessage: 'Can only withdraw pending connections'
})
}
await Connection.findByIdAndDelete(connectionId)
return { success: true }
})

View file

@ -0,0 +1,45 @@
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const [confirmed, pendingOutgoing, pendingIncoming] = await Promise.all([
Connection.find({
status: 'confirmed',
hiddenBy: { $ne: memberId },
$or: [
{ initiator: memberId },
{ recipient: memberId }
]
})
.populate('initiator recipient', 'name avatar craftTags circle')
.sort({ confirmedAt: -1 })
.lean(),
Connection.find({
initiator: memberId,
status: 'pending',
hiddenBy: { $ne: memberId }
})
.populate('recipient', 'name avatar craftTags circle')
.sort({ createdAt: -1 })
.lean(),
Connection.find({
recipient: memberId,
status: 'pending',
hiddenBy: { $ne: memberId }
})
.populate('initiator', 'name avatar craftTags circle')
.sort({ createdAt: -1 })
.lean()
])
return {
confirmed,
pendingOutgoing,
pendingIncoming
}
})

View file

@ -0,0 +1,108 @@
import mongoose from 'mongoose'
import Member from '../../models/member.js'
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const body = await readBody(event)
const { recipientId } = body || {}
if (!recipientId) {
throw createError({
statusCode: 400,
statusMessage: 'recipientId is required'
})
}
if (!mongoose.Types.ObjectId.isValid(recipientId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid recipientId'
})
}
if (recipientId === memberId.toString()) {
throw createError({
statusCode: 400,
statusMessage: 'Cannot connect with yourself'
})
}
// Verify recipient exists and is active
const recipient = await Member.findById(recipientId).lean()
if (!recipient || recipient.status !== 'active') {
throw createError({
statusCode: 404,
statusMessage: 'Recipient not found or not active'
})
}
// Check for existing connection in either direction
const existing = await Connection.findOne({
$or: [
{ initiator: memberId, recipient: recipientId },
{ initiator: recipientId, recipient: memberId }
]
})
if (existing) {
// If reverse pending connection exists, auto-confirm
if (
existing.status === 'pending' &&
existing.initiator.toString() === recipientId &&
existing.recipient.toString() === memberId.toString()
) {
existing.status = 'confirmed'
existing.confirmedAt = new Date()
await existing.save()
logActivity(memberId, 'connection_confirmed', { memberName: recipient.name })
logActivity(recipientId, 'connection_confirmed', { memberName: member.name })
return {
connection: existing,
autoConfirmed: true
}
}
throw createError({
statusCode: 409,
statusMessage: 'Connection already exists'
})
}
// Snapshot matching tags between the two members
const myTopics = member.communityConnections?.topics || []
const theirTopics = recipient.communityConnections?.topics || []
const myTopicMap = {}
for (const t of myTopics) {
myTopicMap[t.tagSlug] = t.state
}
const matchingTags = []
for (const t of theirTopics) {
const myState = myTopicMap[t.tagSlug]
if (myState) {
matchingTags.push({
tagSlug: t.tagSlug,
initiatorState: myState,
recipientState: t.state
})
}
}
const connection = await Connection.create({
initiator: memberId,
recipient: recipientId,
status: 'pending',
matchingTags
})
logActivity(memberId, 'connection_requested', { memberName: recipient.name })
logActivity(recipientId, 'connection_requested', { memberName: member.name })
return { connection }
})

View file

@ -0,0 +1,14 @@
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const count = await Connection.countDocuments({
recipient: member._id,
status: 'pending',
hiddenBy: { $ne: member._id }
})
return { count }
})

View file

@ -0,0 +1,131 @@
import Member from '../../models/member.js'
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const topics = member.communityConnections?.topics || []
if (!topics.length) {
return { suggestions: [] }
}
const query = getQuery(event)
const filterTag = query.tag || null
const filterState = query.state || null
const showHidden = query.showHidden === 'true'
// Build the set of tag slugs to match against
let myTopics = topics
if (filterTag) {
myTopics = myTopics.filter(t => t.tagSlug === filterTag)
}
if (filterState) {
myTopics = myTopics.filter(t => t.state === filterState)
}
if (!myTopics.length) {
return { suggestions: [] }
}
const mySlugs = myTopics.map(t => t.tagSlug)
// Find active members sharing at least one topic slug
const candidates = await Member.find({
_id: { $ne: memberId },
status: 'active',
'communityConnections.topics.tagSlug': { $in: mySlugs }
})
.select('name avatar craftTags circle communityConnections privacy')
.lean()
if (!candidates.length) {
return { suggestions: [] }
}
const candidateIds = candidates.map(c => c._id)
// Find existing connections (pending or confirmed) to exclude
const existingConnections = await Connection.find({
$or: [
{ initiator: memberId, recipient: { $in: candidateIds } },
{ recipient: memberId, initiator: { $in: candidateIds } }
]
})
.select('initiator recipient hiddenBy status')
.lean()
// Build sets for exclusion
const excludeIds = new Set()
for (const conn of existingConnections) {
const otherId = conn.initiator.toString() === memberId.toString()
? conn.recipient.toString()
: conn.initiator.toString()
// Exclude if confirmed or pending connection exists
if (conn.status === 'confirmed' || conn.status === 'pending') {
excludeIds.add(otherId)
}
// Exclude if current member has hidden this connection (unless showHidden)
if (!showHidden && conn.hiddenBy?.some(id => id.toString() === memberId.toString())) {
excludeIds.add(otherId)
}
}
// Build topic lookup for current member (using filtered topics)
const myTopicMap = {}
for (const t of myTopics) {
myTopicMap[t.tagSlug] = t.state
}
// Compute suggestions
const suggestions = []
for (const candidate of candidates) {
if (excludeIds.has(candidate._id.toString())) continue
const theirTopics = candidate.communityConnections?.topics || []
const matchingTags = []
for (const theirTopic of theirTopics) {
const myState = myTopicMap[theirTopic.tagSlug]
if (!myState) continue
matchingTags.push({
tagSlug: theirTopic.tagSlug,
yourState: myState,
theirState: theirTopic.state
})
}
if (!matchingTags.length) continue
// Apply privacy filtering — only expose fields the member allows for other members
const privacy = candidate.privacy || {}
const filtered = {
_id: candidate._id,
name: candidate.name,
circle: candidate.circle,
}
const avatarPrivacy = privacy.avatar || 'public'
if (avatarPrivacy === 'public' || avatarPrivacy === 'members') {
filtered.avatar = candidate.avatar
}
const craftTagsPrivacy = privacy.craftTags || 'members'
if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') {
filtered.craftTags = candidate.craftTags
}
suggestions.push({
member: filtered,
matchingTags,
matchCount: matchingTags.length
})
}
// Sort by overlap count descending
suggestions.sort((a, b) => b.matchCount - a.matchCount)
return { suggestions }
})

View file

@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
status: "active",
})
.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();
@ -70,6 +70,21 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
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
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {

View file

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import Tag from "../../models/tag.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
const tags = query.tags ? query.tags.split(",") : [];
const peerSupport = query.peerSupport || "";
const topics = query.topics ? query.topics.split(",") : [];
const craftTag = query.craftTag || "";
const connectionTag = query.connectionTag || "";
// Build query
const dbQuery = {
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
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") {
dbQuery["peerSupport.enabled"] = true;
andConditions.push({
$or: [
{ "peerSupport.enabled": true },
{ "communityConnections.offerPeerSupport": true },
],
});
}
// Search by name or bio
if (search) {
// Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$or = [
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
andConditions.push({
$or: [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
];
],
});
}
// Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) {
dbQuery.$or = [
{ "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" } },
],
},
{
andConditions.push({
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
},
];
delete dbQuery.$or;
}
});
}
// Filter by peer support topics
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
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 {
const members = await Member.find(dbQuery)
.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 })
.lean();
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
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
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
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
.flatMap((m) => [
...(m.offering?.tags || []),
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
.filter((topic, index, self) => self.indexOf(topic) === index)
.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 {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allTags,
availableTopics: allTopics,
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
cooperativeTags: cooperativeTags.map((t) => ({
slug: t.slug,
label: t.label,
})),
},
};
} catch (error) {

View file

@ -0,0 +1,95 @@
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const member = await requireAuth(event)
const body = await validateBody(event, communityConnectionsUpdateSchema)
// Build update object for community connections settings
const updateData = {
'communityConnections.topics': body.topics || [],
'communityConnections.offerPeerSupport': body.offerPeerSupport || false,
'communityConnections.availability': body.availability || '',
'communityConnections.slackHandle': body.slackHandle || '',
'communityConnections.personalMessage': body.personalMessage || '',
'communityConnections.details': body.details || '',
}
// If Slack handle provided and peer support offered, try to fetch Slack user ID and open DM
if (body.offerPeerSupport && body.slackHandle) {
try {
console.log(
`[Community Connections] Attempting to fetch Slack user ID for: ${body.slackHandle}`,
)
const { getSlackService } = await import('../../../utils/slack.ts')
const slackService = getSlackService()
if (slackService) {
console.log('[Community Connections] Slack service initialized, looking up user...')
const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
if (slackUserId) {
updateData['slackUserId'] = slackUserId
console.log(
`[Community Connections] ✓ Found Slack user ID for ${body.slackHandle}: ${slackUserId}`,
)
console.log('[Community Connections] Opening DM channel...')
const dmChannelId = await slackService.openDMChannel(slackUserId)
if (dmChannelId) {
updateData['communityConnections.slackDMChannelId'] = dmChannelId
console.log(`[Community Connections] ✓ Got DM channel ID: ${dmChannelId}`)
} else {
console.warn('[Community Connections] Could not get DM channel ID')
}
} else {
console.warn(
`[Community Connections] Could not find Slack user ID for handle: ${body.slackHandle}`,
)
}
} else {
console.log('[Community Connections] Slack service not configured, skipping user ID lookup')
}
} catch (error) {
console.error('[Community Connections] Error fetching Slack user ID:', error.message)
console.error('[Community Connections] Stack trace:', error.stack)
// Continue anyway - we'll still save the handle
}
}
try {
const updated = await Member.findByIdAndUpdate(
member._id,
{ $set: updateData },
{ new: true, runValidators: true },
)
if (!updated) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found',
})
}
logActivity(member._id, 'community_connections_updated', {
topicCount: (body.topics || []).length,
offerPeerSupport: body.offerPeerSupport || false,
})
return {
success: true,
communityConnections: updated.communityConnections,
}
} catch (error) {
if (error.statusCode) throw error
console.error('Community connections update error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update community connections settings',
})
}
})

View file

@ -33,6 +33,8 @@ export default defineEventHandler(async (event) => {
"socialLinksPrivacy",
"offeringPrivacy",
"lookingForPrivacy",
"craftTagsPrivacy",
"communityConnectionsPrivacy",
];
// Build update object from validated data
@ -44,6 +46,11 @@ export default defineEventHandler(async (event) => {
}
});
// Handle craftTags (simple array)
if (body.craftTags !== undefined) {
updateData.craftTags = body.craftTags;
}
// Handle offering and lookingFor separately (nested objects)
if (body.offering !== undefined) {
updateData.offering = {
@ -102,6 +109,7 @@ export default defineEventHandler(async (event) => {
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
craftTags: member.craftTags,
showInDirectory: member.showInDirectory,
notifications: member.notifications,
};

View file

@ -0,0 +1,16 @@
import Tag from '../../models/tag.js'
export default defineEventHandler(async (event) => {
await connectDB()
const query = getQuery(event)
const filter = { active: true }
if (query.pool) {
filter.pool = query.pool
}
const tags = await Tag.find(filter).sort({ label: 1 }).lean()
return { tags }
})

View file

@ -0,0 +1,17 @@
import TagSuggestion from '../../models/tagSuggestion.js'
import { tagSuggestionSchema } from '../../utils/schemas.js'
export default defineEventHandler(async (event) => {
await connectDB()
const member = await requireAuth(event)
const body = await validateBody(event, tagSuggestionSchema)
await TagSuggestion.create({
label: body.label,
pool: body.pool,
suggestedBy: member._id
})
return { success: true }
})

View file

@ -0,0 +1,213 @@
/**
* Migration Script: Community Connections
*
* Migrates existing member peer support and tag data to the new
* communityConnections schema.
*
* What this does:
* 1. Builds a slug lookup from all cooperative tags in the database
* 2. For each member with offering.tags or lookingFor.tags:
* - Maps offering.tags communityConnections.topics with state "help"
* - Maps lookingFor.tags communityConnections.topics with state "seeking"
* 3. Copies peerSupport.enabled communityConnections.offerPeerSupport
* 4. Copies peerSupport.availability, peerSupport.personalMessage,
* peerSupport.slackUsername communityConnections.availability,
* .personalMessage, .slackHandle
* 5. Does NOT delete old fields (non-destructive)
*
* Safe to re-run: skips members whose communityConnections is already populated.
*/
import 'dotenv/config'
import mongoose from 'mongoose'
import Tag from '../models/tag.js'
import Member from '../models/member.js'
import { connectDB } from '../utils/mongoose.js'
async function buildCoopTagLookup() {
const coopTags = await Tag.find({ pool: 'cooperative', active: true }).lean()
// Maps normalized label → slug, and slug → slug (for direct slug matches)
const lookup = new Map()
for (const tag of coopTags) {
lookup.set(tag.label.toLowerCase(), tag.slug)
lookup.set(tag.slug.toLowerCase(), tag.slug)
}
return lookup
}
function resolveTagSlugs(rawTags, lookup) {
const matched = []
const unmatched = []
for (const raw of rawTags) {
const normalized = raw.toLowerCase().trim()
if (lookup.has(normalized)) {
matched.push(lookup.get(normalized))
} else {
unmatched.push(raw)
}
}
return { matched, unmatched }
}
async function migrateCommunityConnections() {
await connectDB()
console.log('Building cooperative tag lookup...')
const coopLookup = await buildCoopTagLookup()
console.log(` Loaded ${coopLookup.size / 2} cooperative tags`)
// Find members that have anything to migrate and haven't been migrated yet.
// A member is considered already migrated if communityConnections.topics has entries
// or offerPeerSupport is explicitly set.
const members = await Member.find({
$or: [
{ 'offering.tags': { $exists: true, $ne: [] } },
{ 'lookingFor.tags': { $exists: true, $ne: [] } },
{ 'peerSupport.enabled': { $exists: true } },
{ 'peerSupport.availability': { $exists: true } },
{ 'peerSupport.personalMessage': { $exists: true } },
{ 'peerSupport.slackUsername': { $exists: true } },
],
}).lean()
console.log(`\nFound ${members.length} member(s) with data to migrate`)
let migratedCount = 0
let skippedCount = 0
let totalTagsMatched = 0
const allUnmatched = []
for (const member of members) {
const label = `${member.name || member.email} (${member._id})`
// Skip if already migrated (topics array has entries or offerPeerSupport is set)
const cc = member.communityConnections || {}
const alreadyMigrated =
(cc.topics && cc.topics.length > 0) ||
cc.offerPeerSupport === true ||
cc.availability ||
cc.slackHandle ||
cc.personalMessage
if (alreadyMigrated) {
console.log(` skip ${label} — communityConnections already populated`)
skippedCount++
continue
}
const topics = []
const memberUnmatched = []
// Map offering.tags → state "help"
const offeringTags = member.offering?.tags || []
if (offeringTags.length > 0) {
const { matched, unmatched } = resolveTagSlugs(offeringTags, coopLookup)
for (const slug of matched) {
// Avoid duplicates
if (!topics.find((t) => t.tagSlug === slug)) {
topics.push({ tagSlug: slug, state: 'help' })
}
}
totalTagsMatched += matched.length
if (unmatched.length > 0) {
memberUnmatched.push(...unmatched.map((t) => `offering: "${t}"`))
}
}
// Map lookingFor.tags → state "seeking"
const lookingForTags = member.lookingFor?.tags || []
if (lookingForTags.length > 0) {
const { matched, unmatched } = resolveTagSlugs(lookingForTags, coopLookup)
for (const slug of matched) {
const existing = topics.find((t) => t.tagSlug === slug)
if (existing) {
// Upgrade "help" to "seeking" if it appears in both (or keep as-is — use seeking)
existing.state = 'seeking'
} else {
topics.push({ tagSlug: slug, state: 'seeking' })
}
}
totalTagsMatched += matched.length
if (unmatched.length > 0) {
memberUnmatched.push(...unmatched.map((t) => `lookingFor: "${t}"`))
}
}
if (memberUnmatched.length > 0) {
allUnmatched.push({ member: label, tags: memberUnmatched })
}
// Build communityConnections update
const ccUpdate = {}
if (topics.length > 0) {
ccUpdate['communityConnections.topics'] = topics
}
if (typeof member.peerSupport?.enabled === 'boolean') {
ccUpdate['communityConnections.offerPeerSupport'] = member.peerSupport.enabled
}
if (member.peerSupport?.availability) {
ccUpdate['communityConnections.availability'] = member.peerSupport.availability
}
if (member.peerSupport?.personalMessage) {
ccUpdate['communityConnections.personalMessage'] = member.peerSupport.personalMessage
}
if (member.peerSupport?.slackUsername) {
ccUpdate['communityConnections.slackHandle'] = member.peerSupport.slackUsername
}
if (Object.keys(ccUpdate).length === 0) {
console.log(` skip ${label} — nothing to migrate`)
skippedCount++
continue
}
await Member.findByIdAndUpdate(
member._id,
{ $set: ccUpdate },
{ runValidators: false }
)
console.log(
` migrated ${label}` +
(topics.length > 0 ? `${topics.length} topic(s)` : '') +
(memberUnmatched.length > 0 ? `${memberUnmatched.length} unmatched` : '')
)
migratedCount++
}
console.log('\n=== Migration Summary ===')
console.log(` Total candidates: ${members.length}`)
console.log(` Migrated: ${migratedCount}`)
console.log(` Skipped: ${skippedCount}`)
console.log(` Tags matched: ${totalTagsMatched}`)
if (allUnmatched.length > 0) {
console.log(`\n Unmatched tags (${allUnmatched.length} member(s)):`)
for (const { member, tags } of allUnmatched) {
console.log(` ${member}`)
for (const t of tags) {
console.log(` - ${t}`)
}
}
} else {
console.log(' Unmatched tags: none')
}
}
migrateCommunityConnections()
.then(() => {
console.log('\nMigration completed successfully')
process.exit(0)
})
.catch((err) => {
console.error('\nMigration failed:', err)
process.exit(1)
})
.finally(() => {
mongoose.connection.close()
})

View file

@ -17,7 +17,11 @@ const ACTIVITY_TYPES = [
'role_changed',
'admin_profile_update',
'slack_invited',
'email_sent'
'email_sent',
'community_connections_updated',
'connection_requested',
'connection_confirmed',
'tag_suggested'
]
const activityLogSchema = new mongoose.Schema({

View file

@ -0,0 +1,22 @@
import mongoose from 'mongoose'
const connectionSchema = new mongoose.Schema({
initiator: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true },
recipient: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true },
status: { type: String, enum: ['pending', 'confirmed'], default: 'pending' },
matchingTags: [
{
tagSlug: String,
initiatorState: { type: String, enum: ['help', 'interested', 'seeking'] },
recipientState: { type: String, enum: ['help', 'interested', 'seeking'] },
},
],
hiddenBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Member' }],
createdAt: { type: Date, default: Date.now },
confirmedAt: Date,
})
connectionSchema.index({ initiator: 1, recipient: 1 }, { unique: true })
connectionSchema.index({ recipient: 1, status: 1 })
export default mongoose.models.Connection || mongoose.model('Connection', connectionSchema)

View file

@ -90,6 +90,21 @@ const memberSchema = new mongoose.Schema({
slackDMChannelId: String, // DM channel ID for direct messaging
},
craftTags: [String],
communityConnections: {
topics: [
{
tagSlug: String,
state: { type: String, enum: ['help', 'interested', 'seeking'] },
},
],
offerPeerSupport: { type: Boolean, default: false },
availability: String,
slackHandle: String,
personalMessage: String,
details: String,
},
// Privacy settings for profile fields
privacy: {
pronouns: {
@ -137,12 +152,23 @@ const memberSchema = new mongoose.Schema({
enum: ["public", "members", "private"],
default: "members",
},
craftTags: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
communityConnections: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
},
notifications: {
events: { type: Boolean, default: true },
updates: { type: Boolean, default: true },
peerRequests: { type: Boolean, default: true },
connectionRequests: { type: Boolean, default: true },
},
inviteEmailSent: { type: Boolean, default: false },

13
server/models/tag.js Normal file
View file

@ -0,0 +1,13 @@
import mongoose from 'mongoose'
const tagSchema = new mongoose.Schema({
slug: { type: String, required: true, unique: true },
label: { type: String, required: true },
pool: { type: String, enum: ['craft', 'cooperative'], required: true },
active: { type: Boolean, default: true },
createdAt: { type: Date, default: Date.now },
})
tagSchema.index({ pool: 1, active: 1 })
export default mongoose.models.Tag || mongoose.model('Tag', tagSchema)

View file

@ -0,0 +1,13 @@
import mongoose from 'mongoose'
const tagSuggestionSchema = new mongoose.Schema({
label: { type: String, required: true },
pool: { type: String, enum: ['craft', 'cooperative'], required: true },
suggestedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true },
status: { type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending' },
createdAt: { type: Date, default: Date.now },
})
tagSuggestionSchema.index({ pool: 1, status: 1 })
export default mongoose.models.TagSuggestion || mongoose.model('TagSuggestion', tagSuggestionSchema)

View file

@ -17,7 +17,11 @@ export const ACTIVITY_TYPES = {
ROLE_CHANGED: 'role_changed',
ADMIN_PROFILE_UPDATE: 'admin_profile_update',
SLACK_INVITED: 'slack_invited',
EMAIL_SENT: 'email_sent'
EMAIL_SENT: 'email_sent',
COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated',
CONNECTION_REQUESTED: 'connection_requested',
CONNECTION_CONFIRMED: 'connection_confirmed',
TAG_SUGGESTED: 'tag_suggested'
}
export const ACTIVITY_TYPE_DEFAULTS = {
@ -37,7 +41,11 @@ export const ACTIVITY_TYPE_DEFAULTS = {
role_changed: 'admin',
admin_profile_update: 'admin',
slack_invited: 'admin',
email_sent: 'member'
email_sent: 'member',
community_connections_updated: 'member',
connection_requested: 'member',
connection_confirmed: 'member',
tag_suggested: 'member'
}
/**

View file

@ -38,7 +38,8 @@ export const memberProfileUpdateSchema = z.object({
notifications: z.object({
events: z.boolean().optional(),
updates: z.boolean().optional(),
peerRequests: z.boolean().optional()
peerRequests: z.boolean().optional(),
connectionRequests: z.boolean().optional()
}).optional(),
pronounsPrivacy: privacyEnum.optional(),
timeZonePrivacy: privacyEnum.optional(),
@ -48,7 +49,10 @@ export const memberProfileUpdateSchema = z.object({
locationPrivacy: privacyEnum.optional(),
socialLinksPrivacy: privacyEnum.optional(),
offeringPrivacy: privacyEnum.optional(),
lookingForPrivacy: privacyEnum.optional()
lookingForPrivacy: privacyEnum.optional(),
craftTags: z.array(z.string().max(100)).max(16).optional(),
craftTagsPrivacy: privacyEnum.optional(),
communityConnectionsPrivacy: privacyEnum.optional()
})
export const eventRegistrationSchema = z.object({
@ -346,3 +350,22 @@ export const memberInviteSchema = z.object({
memberIds: z.array(z.string().min(1)).min(1).max(100),
emailTemplate: z.string().min(1).max(10000)
})
// --- Tag schemas ---
export const tagSuggestionSchema = z.object({
label: z.string().min(1).max(100),
pool: z.enum(['craft', 'cooperative'])
})
export const communityConnectionsUpdateSchema = z.object({
topics: z.array(z.object({
tagSlug: z.string().min(1).max(100),
state: z.enum(['help', 'interested', 'seeking'])
})).max(20).optional(),
offerPeerSupport: z.boolean().optional(),
availability: z.string().max(500).optional(),
slackHandle: z.string().max(200).optional(),
personalMessage: z.string().max(2000).optional(),
details: z.string().max(300).optional()
})