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:
commit
689548e389
33 changed files with 2743 additions and 407 deletions
|
|
@ -34,8 +34,13 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink
|
|
||||||
>
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
<span
|
||||||
|
v-if="item.path === '/connections' && pendingCount > 0"
|
||||||
|
class="nav-badge"
|
||||||
|
>{{ pendingCount }}</span>
|
||||||
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -129,7 +134,21 @@ const emit = defineEmits(["navigate"]);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const { getPendingCount } = useConnections();
|
||||||
const isDev = import.meta.dev;
|
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 = () => {
|
const handleNavigate = () => {
|
||||||
if (props.isMobile) {
|
if (props.isMobile) {
|
||||||
|
|
@ -173,7 +192,8 @@ const youItems = [
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
{ label: "Events", path: "/events" },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: "Members", path: "/members" },
|
{ label: "Members", path: "/members" },
|
||||||
{ label: "Wiki", path: "/wiki" },
|
{ label: "Connections", path: "/connections" },
|
||||||
|
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
|
||||||
{ label: "About", path: "/about" },
|
{ label: "About", path: "/about" },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -278,4 +298,19 @@ const exploreItems = [
|
||||||
.sidebar-meta a {
|
.sidebar-meta a {
|
||||||
color: var(--candle-dim);
|
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>
|
</style>
|
||||||
|
|
|
||||||
149
app/components/CooperativeTagSelector.vue
Normal file
149
app/components/CooperativeTagSelector.vue
Normal 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>
|
||||||
95
app/components/CraftTagSelector.vue
Normal file
95
app/components/CraftTagSelector.vue
Normal 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>
|
||||||
106
app/components/TagSuggestModal.vue
Normal file
106
app/components/TagSuggestModal.vue
Normal 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>
|
||||||
32
app/composables/useConnections.js
Normal file
32
app/composables/useConnections.js
Normal 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
823
app/pages/connections.vue
Normal 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">·</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>
|
||||||
|
|
@ -140,45 +140,16 @@
|
||||||
</div>
|
</div>
|
||||||
<PrivacyToggle v-model="formData.bioPrivacy" />
|
<PrivacyToggle v-model="formData.bioPrivacy" />
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label>What I Can Contribute</label>
|
<label>What I Do</label>
|
||||||
<TagInput
|
<CraftTagSelector
|
||||||
v-model="formData.offering.tags"
|
v-model="formData.craftTags"
|
||||||
placeholder="add skill..."
|
:tags="craftTags"
|
||||||
|
@suggest="tagSuggestPool = 'craft'; showTagSuggestModal = true"
|
||||||
/>
|
/>
|
||||||
<PrivacyToggle v-model="formData.offeringPrivacy" />
|
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -206,11 +177,34 @@
|
||||||
<!-- ======== RIGHT COLUMN ======== -->
|
<!-- ======== RIGHT COLUMN ======== -->
|
||||||
<div class="profile-col-right">
|
<div class="profile-col-right">
|
||||||
<div class="profile-col-inset">
|
<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">
|
<div class="toggle-field">
|
||||||
<USwitch
|
<USwitch
|
||||||
v-model="formData.peerSupportEnabled"
|
v-model="formData.communityConnectionsOfferPeerSupport"
|
||||||
aria-label="Offer Peer Support"
|
aria-label="Offer Peer Support"
|
||||||
/>
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
|
|
@ -221,47 +215,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="formData.peerSupportEnabled" class="peer-panel">
|
<div v-if="formData.communityConnectionsOfferPeerSupport" class="connections-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">✓</span>
|
|
||||||
{{ topic }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Availability</label>
|
<label>Availability</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="formData.peerSupportAvailability"
|
v-model="formData.communityConnectionsAvailability"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="e.g. Weekday afternoons ET"
|
placeholder="e.g. Weekday afternoons ET"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -270,7 +228,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Slack Handle</label>
|
<label>Slack Handle</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.peerSupportSlackUsername"
|
v-model="formData.communityConnectionsSlackHandle"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="@yourslackname"
|
placeholder="@yourslackname"
|
||||||
/>
|
/>
|
||||||
|
|
@ -279,13 +237,13 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Personal Message</label>
|
<label>Personal Message</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="formData.peerSupportMessage"
|
v-model="formData.communityConnectionsPersonalMessage"
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
placeholder="Brief note shown to people requesting time with you"
|
placeholder="Brief note shown to people requesting time with you"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="char-count">
|
<div class="char-count">
|
||||||
{{ formData.peerSupportMessage?.length || 0 }} / 200
|
{{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -324,11 +282,11 @@
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch
|
<USwitch
|
||||||
v-model="formData.notifyPeerRequests"
|
v-model="formData.notifyConnectionRequests"
|
||||||
aria-label="Peer support requests"
|
aria-label="Connection requests"
|
||||||
/>
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Peer support requests
|
Connection requests
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
>When someone wants to connect</span
|
>When someone wants to connect</span
|
||||||
>
|
>
|
||||||
|
|
@ -360,6 +318,9 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag Suggest Modal -->
|
||||||
|
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -389,6 +350,20 @@ const availableGhosts = [
|
||||||
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
|
{ 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
|
// Form state
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -398,16 +373,18 @@ const formData = reactive({
|
||||||
studio: "",
|
studio: "",
|
||||||
bio: "",
|
bio: "",
|
||||||
location: "",
|
location: "",
|
||||||
offering: { text: "", tags: [] },
|
|
||||||
lookingFor: { text: "", tags: [] },
|
|
||||||
showInDirectory: true,
|
showInDirectory: true,
|
||||||
// Peer support
|
// Craft tags
|
||||||
peerSupportEnabled: false,
|
craftTags: [],
|
||||||
peerSupportSkillTopics: [],
|
craftTagsPrivacy: "members",
|
||||||
peerSupportSupportTopics: [],
|
// Community connections
|
||||||
peerSupportAvailability: "",
|
communityConnectionsTopics: [],
|
||||||
peerSupportMessage: "",
|
communityConnectionsPrivacy: "members",
|
||||||
peerSupportSlackUsername: "",
|
communityConnectionsDetails: "",
|
||||||
|
communityConnectionsOfferPeerSupport: false,
|
||||||
|
communityConnectionsAvailability: "",
|
||||||
|
communityConnectionsSlackHandle: "",
|
||||||
|
communityConnectionsPersonalMessage: "",
|
||||||
// Privacy
|
// Privacy
|
||||||
pronounsPrivacy: "members",
|
pronounsPrivacy: "members",
|
||||||
timeZonePrivacy: "members",
|
timeZonePrivacy: "members",
|
||||||
|
|
@ -415,12 +392,10 @@ const formData = reactive({
|
||||||
studioPrivacy: "members",
|
studioPrivacy: "members",
|
||||||
bioPrivacy: "members",
|
bioPrivacy: "members",
|
||||||
locationPrivacy: "members",
|
locationPrivacy: "members",
|
||||||
offeringPrivacy: "members",
|
|
||||||
lookingForPrivacy: "members",
|
|
||||||
// Notifications
|
// Notifications
|
||||||
notifyEvents: true,
|
notifyEvents: true,
|
||||||
notifyUpdates: true,
|
notifyUpdates: true,
|
||||||
notifyPeerRequests: true,
|
notifyConnectionRequests: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
@ -429,47 +404,11 @@ const saveSuccess = ref(false);
|
||||||
const saveError = ref(null);
|
const saveError = ref(null);
|
||||||
const initialData = 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
|
// Computed
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
|
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
|
// Load member data into form
|
||||||
const loadProfile = () => {
|
const loadProfile = () => {
|
||||||
if (memberData.value) {
|
if (memberData.value) {
|
||||||
|
|
@ -481,66 +420,21 @@ const loadProfile = () => {
|
||||||
formData.bio = memberData.value.bio || "";
|
formData.bio = memberData.value.bio || "";
|
||||||
formData.location = memberData.value.location || "";
|
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;
|
formData.showInDirectory = memberData.value.showInDirectory ?? true;
|
||||||
|
|
||||||
// Load peer support data
|
// Load craft tags
|
||||||
if (memberData.value.peerSupport) {
|
formData.craftTags = Array.isArray(memberData.value.craftTags)
|
||||||
formData.peerSupportEnabled =
|
? [...memberData.value.craftTags]
|
||||||
memberData.value.peerSupport.enabled || false;
|
: [];
|
||||||
formData.peerSupportSkillTopics = Array.isArray(
|
|
||||||
memberData.value.peerSupport.skillTopics,
|
// Load community connections (with fallback to old peerSupport fields)
|
||||||
)
|
const cc = memberData.value.communityConnections || {};
|
||||||
? [...memberData.value.peerSupport.skillTopics]
|
formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : [];
|
||||||
: [];
|
formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false;
|
||||||
formData.peerSupportSupportTopics = Array.isArray(
|
formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || "";
|
||||||
memberData.value.peerSupport.supportTopics,
|
formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || "";
|
||||||
)
|
formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || "";
|
||||||
? [...memberData.value.peerSupport.supportTopics]
|
formData.communityConnectionsDetails = cc.details || "";
|
||||||
: [];
|
|
||||||
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 privacy settings (with defaults)
|
// Load privacy settings (with defaults)
|
||||||
const privacy = memberData.value.privacy || {};
|
const privacy = memberData.value.privacy || {};
|
||||||
|
|
@ -550,14 +444,14 @@ const loadProfile = () => {
|
||||||
formData.studioPrivacy = privacy.studio || "members";
|
formData.studioPrivacy = privacy.studio || "members";
|
||||||
formData.bioPrivacy = privacy.bio || "members";
|
formData.bioPrivacy = privacy.bio || "members";
|
||||||
formData.locationPrivacy = privacy.location || "members";
|
formData.locationPrivacy = privacy.location || "members";
|
||||||
formData.offeringPrivacy = privacy.offering || "members";
|
formData.craftTagsPrivacy = privacy.craftTags || "members";
|
||||||
formData.lookingForPrivacy = privacy.lookingFor || "members";
|
formData.communityConnectionsPrivacy = privacy.communityConnections || "members";
|
||||||
|
|
||||||
// Load notification prefs
|
// Load notification prefs
|
||||||
const notifs = memberData.value.notifications || {};
|
const notifs = memberData.value.notifications || {};
|
||||||
formData.notifyEvents = notifs.events ?? true;
|
formData.notifyEvents = notifs.events ?? true;
|
||||||
formData.notifyUpdates = notifs.updates ?? true;
|
formData.notifyUpdates = notifs.updates ?? true;
|
||||||
formData.notifyPeerRequests = notifs.peerRequests ?? true;
|
formData.notifyConnectionRequests = notifs.connectionRequests ?? notifs.peerRequests ?? true;
|
||||||
|
|
||||||
// Store initial state for change detection
|
// Store initial state for change detection
|
||||||
initialData.value = JSON.parse(JSON.stringify(formData));
|
initialData.value = JSON.parse(JSON.stringify(formData));
|
||||||
|
|
@ -571,29 +465,30 @@ const handleSubmit = async () => {
|
||||||
saveError.value = null;
|
saveError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save profile data
|
// Save profile data (includes craft tags + privacy + notifications)
|
||||||
await $fetch("/api/members/profile", {
|
await $fetch("/api/members/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
...formData,
|
...formData,
|
||||||
|
craftTags: formData.craftTags,
|
||||||
notifications: {
|
notifications: {
|
||||||
events: formData.notifyEvents,
|
events: formData.notifyEvents,
|
||||||
updates: formData.notifyUpdates,
|
updates: formData.notifyUpdates,
|
||||||
peerRequests: formData.notifyPeerRequests,
|
connectionRequests: formData.notifyConnectionRequests,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save peer support data separately
|
// Save community connections data
|
||||||
await $fetch("/api/members/me/peer-support", {
|
await $fetch("/api/members/me/community-connections", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
enabled: formData.peerSupportEnabled,
|
topics: formData.communityConnectionsTopics,
|
||||||
skillTopics: formData.peerSupportSkillTopics,
|
offerPeerSupport: formData.communityConnectionsOfferPeerSupport,
|
||||||
supportTopics: formData.peerSupportSupportTopics,
|
availability: formData.communityConnectionsAvailability,
|
||||||
availability: formData.peerSupportAvailability,
|
slackHandle: formData.communityConnectionsSlackHandle,
|
||||||
personalMessage: formData.peerSupportMessage,
|
personalMessage: formData.communityConnectionsPersonalMessage,
|
||||||
slackUsername: formData.peerSupportSlackUsername,
|
details: formData.communityConnectionsDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -844,48 +739,8 @@ useHead({
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CHECKBOX GRID ---- */
|
/* ---- CONNECTIONS PANEL ---- */
|
||||||
.checkbox-grid {
|
.connections-panel {
|
||||||
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 {
|
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
@ -893,19 +748,6 @@ useHead({
|
||||||
background: var(--surface);
|
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 ---- */
|
/* ---- DISABLED BUTTON ---- */
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|
@ -962,10 +804,6 @@ useHead({
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-col-left .profile-col-inset,
|
.profile-col-left .profile-col-inset,
|
||||||
.profile-col-right .profile-col-inset {
|
.profile-col-right .profile-col-inset {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
|
|
|
||||||
|
|
@ -60,40 +60,42 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Offering Section -->
|
<!-- What I Do (craft tags, falling back to offering) -->
|
||||||
<div
|
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
|
||||||
v-if="member.offering?.tags?.length || member.offering?.text"
|
<div class="section-label">What I Do</div>
|
||||||
class="profile-section"
|
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
||||||
>
|
|
||||||
<div class="section-label">Offering</div>
|
|
||||||
<div v-if="member.offering.tags?.length" class="tag-list">
|
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.offering.tags"
|
v-for="tag in craftTagsDisplay"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="tag-pill"
|
class="tag-pill"
|
||||||
>{{ tag }}</span
|
>{{ tagLabel('craft', tag) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.offering.text" class="profile-detail offering-text">
|
<p v-if="member.offering?.text" class="profile-detail offering-text">
|
||||||
{{ member.offering.text }}
|
{{ member.offering.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Looking For Section -->
|
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text"
|
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
|
||||||
class="profile-section"
|
class="profile-section"
|
||||||
>
|
>
|
||||||
<div class="section-label">Looking for</div>
|
<div class="section-label">Community Connections</div>
|
||||||
<div v-if="member.lookingFor.tags?.length" class="tag-list">
|
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.lookingFor.tags"
|
v-for="topic in connectionTopicsDisplay"
|
||||||
:key="tag"
|
:key="topic.tagSlug || topic"
|
||||||
class="tag-pill"
|
class="tag-pill connection-pill"
|
||||||
>{{ tag }}</span
|
|
||||||
>
|
>
|
||||||
|
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.lookingFor.text" class="profile-detail looking-text">
|
<p v-if="member.communityConnections?.details" class="profile-detail connection-details">
|
||||||
|
{{ member.communityConnections.details }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
|
||||||
{{ member.lookingFor.text }}
|
{{ member.lookingFor.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,10 +152,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer Support Section -->
|
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
|
||||||
<div v-if="member.peerSupport?.enabled" class="profile-section">
|
<div v-if="showPeerSupport" class="profile-section">
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
<div v-if="member.peerSupport.skillTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Skills:</span>
|
<span class="peer-label">Skills:</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
|
|
@ -164,7 +166,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member.peerSupport.supportTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Topics:</span>
|
<span class="peer-label">Topics:</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
|
|
@ -175,8 +177,8 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.peerSupport.availability" class="profile-detail">
|
<p v-if="peerAvailability" class="profile-detail">
|
||||||
{{ member.peerSupport.availability }}
|
{{ peerAvailability }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -233,6 +235,15 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
practitioner: "Practitioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// State display text mapping
|
||||||
|
const stateLabels = {
|
||||||
|
help: "Can help",
|
||||||
|
interested: "Interested",
|
||||||
|
seeking: "Need help",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
||||||
|
|
||||||
const getInitials = (name) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
|
|
@ -246,6 +257,11 @@ const getInitials = (name) => {
|
||||||
// Fetch member data — no await so the component renders immediately (no Suspense)
|
// Fetch member data — no await so the component renders immediately (no Suspense)
|
||||||
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
|
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
|
||||||
|
|
||||||
|
// Fetch tags for slug-to-label lookup
|
||||||
|
const { data: tagsData } = useFetch("/api/tags", {
|
||||||
|
default: () => ({ tags: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch public activity
|
// Fetch public activity
|
||||||
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
|
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
|
||||||
params: { limit: 5 },
|
params: { limit: 5 },
|
||||||
|
|
@ -267,6 +283,56 @@ const formatRelativeDate = (date) => {
|
||||||
}
|
}
|
||||||
const member = computed(() => data.value?.member || null);
|
const member = computed(() => data.value?.member || null);
|
||||||
|
|
||||||
|
// Tag label lookup
|
||||||
|
const tagLabel = (pool, slug) => {
|
||||||
|
const tags = tagsData.value?.tags || [];
|
||||||
|
const found = tags.find((t) => t.slug === slug && t.pool === pool);
|
||||||
|
return found ? found.label : slug;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Craft tags display: new field, falling back to offering.tags
|
||||||
|
const craftTagsDisplay = computed(() => {
|
||||||
|
if (!member.value) return [];
|
||||||
|
if (member.value.craftTags && member.value.craftTags.length > 0) {
|
||||||
|
return member.value.craftTags;
|
||||||
|
}
|
||||||
|
return member.value.offering?.tags || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection topics display: new field, falling back to lookingFor.tags
|
||||||
|
const connectionTopicsDisplay = computed(() => {
|
||||||
|
if (!member.value) return [];
|
||||||
|
if (
|
||||||
|
member.value.communityConnections?.topics &&
|
||||||
|
member.value.communityConnections.topics.length > 0
|
||||||
|
) {
|
||||||
|
return member.value.communityConnections.topics;
|
||||||
|
}
|
||||||
|
if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) {
|
||||||
|
return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peer support: check both new communityConnections and old peerSupport
|
||||||
|
const showPeerSupport = computed(() => {
|
||||||
|
if (!member.value) return false;
|
||||||
|
return (
|
||||||
|
member.value.communityConnections?.offerPeerSupport ||
|
||||||
|
member.value.peerSupport?.enabled
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peer availability: prefer new field, fall back to old
|
||||||
|
const peerAvailability = computed(() => {
|
||||||
|
if (!member.value) return "";
|
||||||
|
return (
|
||||||
|
member.value.communityConnections?.availability ||
|
||||||
|
member.value.peerSupport?.availability ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||||
watch(
|
watch(
|
||||||
member,
|
member,
|
||||||
|
|
@ -442,7 +508,8 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.offering-text,
|
.offering-text,
|
||||||
.looking-text {
|
.looking-text,
|
||||||
|
.connection-details {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,6 +529,19 @@ useHead({
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- SOCIAL LINKS ---- */
|
/* ---- SOCIAL LINKS ---- */
|
||||||
.social-links {
|
.social-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -41,64 +41,64 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills Filter -->
|
<!-- Craft Tags Filter -->
|
||||||
<div
|
<div
|
||||||
v-if="availableSkills && availableSkills.length > 0"
|
v-if="craftTagOptions.length > 0"
|
||||||
class="skills-bar"
|
class="skills-bar"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Skills:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<button
|
<button
|
||||||
v-for="skill in (availableSkills || []).slice(
|
v-for="tag in craftTagOptions.slice(
|
||||||
0,
|
0,
|
||||||
showAllSkills ? undefined : 10,
|
showAllCraftTags ? undefined : 10,
|
||||||
)"
|
)"
|
||||||
:key="skill"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: selectedSkills.includes(skill) }"
|
:class="{ active: selectedCraftTags.includes(tag.slug) }"
|
||||||
@click="toggleSkill(skill)"
|
@click="toggleCraftTag(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ skill }}
|
{{ tag.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="availableSkills && availableSkills.length > 10"
|
v-if="craftTagOptions.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllSkills = !showAllSkills"
|
@click="showAllCraftTags = !showAllCraftTags"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
|
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Topics Filter -->
|
<!-- Connection Tags Filter -->
|
||||||
<div
|
<div
|
||||||
v-if="availableTopics && availableTopics.length > 0"
|
v-if="connectionTagOptions.length > 0"
|
||||||
class="skills-bar"
|
class="skills-bar"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Topics:</span>
|
<span class="tag-label">Topics:</span>
|
||||||
<button
|
<button
|
||||||
v-for="topic in (availableTopics || []).slice(
|
v-for="tag in connectionTagOptions.slice(
|
||||||
0,
|
0,
|
||||||
showAllTopics ? undefined : 10,
|
showAllConnectionTags ? undefined : 10,
|
||||||
)"
|
)"
|
||||||
:key="topic"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: selectedTopics.includes(topic) }"
|
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
|
||||||
@click="toggleTopic(topic)"
|
@click="toggleConnectionTag(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ topic }}
|
{{ tag.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="availableTopics && availableTopics.length > 10"
|
v-if="connectionTagOptions.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllTopics = !showAllTopics"
|
@click="showAllConnectionTags = !showAllConnectionTags"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
|
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,16 +117,16 @@
|
||||||
Offering Peer Support
|
Offering Peer Support
|
||||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
|
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
|
||||||
{{ skill }}
|
{{ craftTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleSkill(skill)">×</button>
|
<button type="button" @click="toggleCraftTag(slug)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
|
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
|
||||||
{{ topic }}
|
{{ connectionTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleTopic(topic)">×</button>
|
<button type="button" @click="toggleConnectionTag(slug)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="clear-all-btn"
|
class="clear-all-btn"
|
||||||
@click="clearAllFilters"
|
@click="clearAllFilters"
|
||||||
|
|
@ -186,33 +186,38 @@
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills tags -->
|
<!-- Craft tags (fall back to offering.tags) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.offering?.tags && member.offering.tags.length > 0"
|
v-if="getMemberCraftTags(member).length > 0"
|
||||||
class="mc-tags"
|
class="mc-tags"
|
||||||
>
|
>
|
||||||
<span class="tag-label">Skills:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.offering.tags"
|
v-for="tag in getMemberCraftTags(member)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
>{{ tag }}</span
|
>{{ craftTagLabel(tag) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Looking for -->
|
<!-- Community connections topics (fall back to lookingFor.tags) -->
|
||||||
<div
|
<div
|
||||||
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
v-if="getMemberConnectionTopics(member).length > 0"
|
||||||
class="mc-looking"
|
class="mc-looking"
|
||||||
>
|
>
|
||||||
Looking for: {{ member.lookingFor.tags.join(", ") }}
|
<span
|
||||||
|
v-for="topic in getMemberConnectionTopics(member)"
|
||||||
|
:key="topic.tagSlug || topic"
|
||||||
|
class="connection-topic"
|
||||||
|
>
|
||||||
|
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ connectionTagLabel(topic.tagSlug || topic) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer support session link -->
|
<!-- Peer support session link -->
|
||||||
<a
|
<a
|
||||||
v-if="
|
v-if="showPeerSupport(member)"
|
||||||
member.peerSupport?.enabled && member.peerSupport?.slackUsername
|
|
||||||
"
|
|
||||||
href="#"
|
href="#"
|
||||||
class="mc-session"
|
class="mc-session"
|
||||||
@click.prevent="openSlackDM(member)"
|
@click.prevent="openSlackDM(member)"
|
||||||
|
|
@ -270,16 +275,27 @@ const { render: renderMarkdown } = useMarkdown();
|
||||||
// State
|
// State
|
||||||
const members = ref([]);
|
const members = ref([]);
|
||||||
const totalCount = ref(0);
|
const totalCount = ref(0);
|
||||||
const availableSkills = ref([]);
|
|
||||||
const availableTopics = ref([]);
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const selectedCircle = ref("all");
|
const selectedCircle = ref("all");
|
||||||
const peerSupportFilter = ref("all");
|
const peerSupportFilter = ref("all");
|
||||||
const selectedSkills = ref([]);
|
const selectedCraftTags = ref([]);
|
||||||
const selectedTopics = ref([]);
|
const selectedConnectionTags = ref([]);
|
||||||
const showAllSkills = ref(false);
|
const showAllCraftTags = ref(false);
|
||||||
const showAllTopics = ref(false);
|
const showAllConnectionTags = ref(false);
|
||||||
|
|
||||||
|
// Tag options from API
|
||||||
|
const craftTagOptions = ref([]);
|
||||||
|
const connectionTagOptions = ref([]);
|
||||||
|
|
||||||
|
// State display text mapping
|
||||||
|
const stateLabels = {
|
||||||
|
help: "Can help",
|
||||||
|
interested: "Interested",
|
||||||
|
seeking: "Need help",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
||||||
|
|
||||||
// Circle options
|
// Circle options
|
||||||
const circleOptions = [
|
const circleOptions = [
|
||||||
|
|
@ -295,19 +311,55 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
practitioner: "Practitioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Peer support filter options
|
// Tag slug-to-label lookups
|
||||||
const peerSupportOptions = [
|
const craftTagLabel = (slug) => {
|
||||||
{ label: "All Members", value: "all" },
|
const found = craftTagOptions.value.find((t) => t.slug === slug);
|
||||||
{ label: "Offering Peer Support", value: "true" },
|
return found ? found.label : slug;
|
||||||
];
|
};
|
||||||
|
|
||||||
|
const connectionTagLabel = (slug) => {
|
||||||
|
const found = connectionTagOptions.value.find((t) => t.slug === slug);
|
||||||
|
return found ? found.label : slug;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get craft tags for a member (new field, falling back to offering.tags)
|
||||||
|
const getMemberCraftTags = (member) => {
|
||||||
|
if (member.craftTags && member.craftTags.length > 0) {
|
||||||
|
return member.craftTags;
|
||||||
|
}
|
||||||
|
return member.offering?.tags || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get connection topics for a member (new field, falling back to lookingFor.tags)
|
||||||
|
const getMemberConnectionTopics = (member) => {
|
||||||
|
if (
|
||||||
|
member.communityConnections?.topics &&
|
||||||
|
member.communityConnections.topics.length > 0
|
||||||
|
) {
|
||||||
|
return member.communityConnections.topics;
|
||||||
|
}
|
||||||
|
// Fallback: wrap old lookingFor.tags as plain strings
|
||||||
|
if (member.lookingFor?.tags && member.lookingFor.tags.length > 0) {
|
||||||
|
return member.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show peer support link (check both old and new fields)
|
||||||
|
const showPeerSupport = (member) => {
|
||||||
|
if (member.communityConnections?.offerPeerSupport) return true;
|
||||||
|
if (member.peerSupport?.enabled && member.peerSupport?.slackUsername)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Computed: has active filters
|
// Computed: has active filters
|
||||||
const hasActiveFilters = computed(() => {
|
const hasActiveFilters = computed(() => {
|
||||||
return (
|
return (
|
||||||
(selectedCircle.value && selectedCircle.value !== "all") ||
|
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||||
selectedSkills.value.length > 0 ||
|
selectedCraftTags.value.length > 0 ||
|
||||||
selectedTopics.value.length > 0
|
selectedConnectionTags.value.length > 0
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -333,28 +385,51 @@ const loadMembers = async () => {
|
||||||
params.circle = selectedCircle.value;
|
params.circle = selectedCircle.value;
|
||||||
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
||||||
params.peerSupport = peerSupportFilter.value;
|
params.peerSupport = peerSupportFilter.value;
|
||||||
if (selectedSkills.value.length > 0)
|
if (selectedCraftTags.value.length === 1)
|
||||||
params.skills = selectedSkills.value.join(",");
|
params.craftTag = selectedCraftTags.value[0];
|
||||||
if (selectedTopics.value.length > 0)
|
if (selectedConnectionTags.value.length === 1)
|
||||||
params.topics = selectedTopics.value.join(",");
|
params.connectionTag = selectedConnectionTags.value[0];
|
||||||
|
|
||||||
const data = await $fetch("/api/members/directory", { params });
|
const data = await $fetch("/api/members/directory", { params });
|
||||||
|
|
||||||
members.value = data.members || [];
|
members.value = data.members || [];
|
||||||
totalCount.value = data.totalCount || 0;
|
totalCount.value = data.totalCount || 0;
|
||||||
availableSkills.value = data.filters?.availableSkills || [];
|
|
||||||
availableTopics.value = data.filters?.availableTopics || [];
|
// Update tag options from API response (only on initial load or if empty)
|
||||||
|
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
|
||||||
|
craftTagOptions.value = data.filters.craftTags;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.filters?.cooperativeTags &&
|
||||||
|
connectionTagOptions.value.length === 0
|
||||||
|
) {
|
||||||
|
connectionTagOptions.value = data.filters.cooperativeTags;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load members:", error);
|
console.error("Failed to load members:", error);
|
||||||
members.value = [];
|
members.value = [];
|
||||||
totalCount.value = 0;
|
totalCount.value = 0;
|
||||||
availableSkills.value = [];
|
|
||||||
availableTopics.value = [];
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch tag options from API on mount
|
||||||
|
const loadTagOptions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await $fetch("/api/tags");
|
||||||
|
const tags = data.tags || [];
|
||||||
|
craftTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === "craft")
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
|
connectionTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === "cooperative")
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tags:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle peer support checkbox
|
// Toggle peer support checkbox
|
||||||
const togglePeerSupport = (e) => {
|
const togglePeerSupport = (e) => {
|
||||||
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||||
|
|
@ -370,24 +445,24 @@ const debouncedSearch = () => {
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle skill filter
|
// Toggle craft tag filter
|
||||||
const toggleSkill = (skill) => {
|
const toggleCraftTag = (slug) => {
|
||||||
const index = selectedSkills.value.indexOf(skill);
|
const index = selectedCraftTags.value.indexOf(slug);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
selectedSkills.value.splice(index, 1);
|
selectedCraftTags.value.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
selectedSkills.value.push(skill);
|
selectedCraftTags.value = [slug]; // single-select for API query param
|
||||||
}
|
}
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle topic filter
|
// Toggle connection tag filter
|
||||||
const toggleTopic = (topic) => {
|
const toggleConnectionTag = (slug) => {
|
||||||
const index = selectedTopics.value.indexOf(topic);
|
const index = selectedConnectionTags.value.indexOf(slug);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
selectedTopics.value.splice(index, 1);
|
selectedConnectionTags.value.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
selectedTopics.value.push(topic);
|
selectedConnectionTags.value = [slug]; // single-select for API query param
|
||||||
}
|
}
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
@ -407,14 +482,17 @@ const clearAllFilters = () => {
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
selectedCircle.value = "all";
|
selectedCircle.value = "all";
|
||||||
peerSupportFilter.value = "all";
|
peerSupportFilter.value = "all";
|
||||||
selectedSkills.value = [];
|
selectedCraftTags.value = [];
|
||||||
selectedTopics.value = [];
|
selectedConnectionTags.value = [];
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slack DM functionality
|
// Slack DM functionality
|
||||||
const openSlackDM = async (member) => {
|
const openSlackDM = async (member) => {
|
||||||
const username = member.peerSupport?.slackUsername || member.name;
|
const username =
|
||||||
|
member.communityConnections?.slackHandle ||
|
||||||
|
member.peerSupport?.slackUsername ||
|
||||||
|
member.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(username);
|
await navigator.clipboard.writeText(username);
|
||||||
|
|
@ -429,12 +507,13 @@ const openSlackDM = async (member) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load on mount and handle query params
|
// Load on mount and handle query params
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
if (route.query.peerSupport === "true") {
|
if (route.query.peerSupport === "true") {
|
||||||
peerSupportFilter.value = "true";
|
peerSupportFilter.value = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadTagOptions();
|
||||||
loadMembers();
|
loadMembers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -756,10 +835,28 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-looking {
|
.mc-looking {
|
||||||
font-size: 11px;
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-topic {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-style: italic;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-session {
|
.mc-session {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// Redirect to members directory with peer support filter
|
// Redirect to connections page
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: defineNuxtRouteMiddleware(() => {
|
middleware: defineNuxtRouteMiddleware(() => {
|
||||||
return navigateTo("/members?peerSupport=true");
|
return navigateTo("/connections");
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,22 @@ const formatters = {
|
||||||
text: m.subject ? `Email: ${m.subject}` : 'Email sent',
|
text: m.subject ? `Email: ${m.subject}` : 'Email sent',
|
||||||
icon: 'i-lucide-mail',
|
icon: 'i-lucide-mail',
|
||||||
emailBody: m.body || null
|
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
113
scripts/seed-tags.js
Normal 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()
|
||||||
|
})
|
||||||
54
server/api/connections/[id]/confirm.post.js
Normal file
54
server/api/connections/[id]/confirm.post.js
Normal 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 }
|
||||||
|
})
|
||||||
48
server/api/connections/[id]/hide.post.js
Normal file
48
server/api/connections/[id]/hide.post.js
Normal 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 }
|
||||||
|
})
|
||||||
43
server/api/connections/[id]/withdraw.post.js
Normal file
43
server/api/connections/[id]/withdraw.post.js
Normal 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 }
|
||||||
|
})
|
||||||
45
server/api/connections/index.get.js
Normal file
45
server/api/connections/index.get.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
108
server/api/connections/index.post.js
Normal file
108
server/api/connections/index.post.js
Normal 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 }
|
||||||
|
})
|
||||||
14
server/api/connections/pending-count.get.js
Normal file
14
server/api/connections/pending-count.get.js
Normal 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 }
|
||||||
|
})
|
||||||
131
server/api/connections/suggestions.get.js
Normal file
131
server/api/connections/suggestions.get.js
Normal 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 }
|
||||||
|
})
|
||||||
|
|
@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||||
status: "active",
|
status: "active",
|
||||||
})
|
})
|
||||||
.select(
|
.select(
|
||||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
|
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
||||||
)
|
)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
|
@ -70,6 +70,21 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("offering")) filtered.offering = member.offering;
|
if (isVisible("offering")) filtered.offering = member.offering;
|
||||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||||
|
|
||||||
|
// Craft tags
|
||||||
|
if (isVisible("craftTags")) {
|
||||||
|
filtered.craftTags = member.craftTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community connections (expose only public-safe fields)
|
||||||
|
if (isVisible("communityConnections")) {
|
||||||
|
filtered.communityConnections = {
|
||||||
|
topics: member.communityConnections?.topics,
|
||||||
|
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||||
|
availability: member.communityConnections?.availability,
|
||||||
|
details: member.communityConnections?.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Peer support: expose only fields needed for matching/contact UX
|
// Peer support: expose only fields needed for matching/contact UX
|
||||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||||
if (member.peerSupport?.enabled) {
|
if (member.peerSupport?.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
|
import Tag from "../../models/tag.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
|
||||||
const tags = query.tags ? query.tags.split(",") : [];
|
const tags = query.tags ? query.tags.split(",") : [];
|
||||||
const peerSupport = query.peerSupport || "";
|
const peerSupport = query.peerSupport || "";
|
||||||
const topics = query.topics ? query.topics.split(",") : [];
|
const topics = query.topics ? query.topics.split(",") : [];
|
||||||
|
const craftTag = query.craftTag || "";
|
||||||
|
const connectionTag = query.connectionTag || "";
|
||||||
|
|
||||||
// Build query
|
// Build query
|
||||||
const dbQuery = {
|
const dbQuery = {
|
||||||
|
|
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
|
||||||
dbQuery.circle = circle;
|
dbQuery.circle = circle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by peer support availability
|
// Collect $and conditions for combining multiple filters
|
||||||
|
const andConditions = [];
|
||||||
|
|
||||||
|
// Filter by peer support availability (check both old and new fields)
|
||||||
if (peerSupport === "true") {
|
if (peerSupport === "true") {
|
||||||
dbQuery["peerSupport.enabled"] = true;
|
andConditions.push({
|
||||||
|
$or: [
|
||||||
|
{ "peerSupport.enabled": true },
|
||||||
|
{ "communityConnections.offerPeerSupport": true },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search by name or bio
|
// Search by name or bio
|
||||||
if (search) {
|
if (search) {
|
||||||
// Escape special regex characters to prevent ReDoS
|
// Escape special regex characters to prevent ReDoS
|
||||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
dbQuery.$or = [
|
andConditions.push({
|
||||||
{ name: { $regex: escaped, $options: "i" } },
|
$or: [
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
{ name: { $regex: escaped, $options: "i" } },
|
||||||
];
|
{ bio: { $regex: escaped, $options: "i" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by tags (search in offering.tags or lookingFor.tags)
|
// Filter by tags (search in offering.tags or lookingFor.tags)
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
dbQuery.$or = [
|
andConditions.push({
|
||||||
{ "offering.tags": { $in: tags } },
|
$or: [
|
||||||
{ "lookingFor.tags": { $in: tags } },
|
{ "offering.tags": { $in: tags } },
|
||||||
];
|
{ "lookingFor.tags": { $in: tags } },
|
||||||
// If search is also present, combine with AND
|
],
|
||||||
if (search) {
|
});
|
||||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
dbQuery.$and = [
|
|
||||||
{
|
|
||||||
$or: [
|
|
||||||
{ name: { $regex: escaped, $options: "i" } },
|
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$or: [
|
|
||||||
{ "offering.tags": { $in: tags } },
|
|
||||||
{ "lookingFor.tags": { $in: tags } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
delete dbQuery.$or;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by peer support topics
|
// Filter by peer support topics
|
||||||
|
|
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
|
||||||
dbQuery["peerSupport.topics"] = { $in: topics };
|
dbQuery["peerSupport.topics"] = { $in: topics };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by craft tag
|
||||||
|
if (craftTag) {
|
||||||
|
dbQuery.craftTags = craftTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by connection tag
|
||||||
|
if (connectionTag) {
|
||||||
|
dbQuery["communityConnections.topics.tagSlug"] = connectionTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply combined $and conditions
|
||||||
|
if (andConditions.length > 0) {
|
||||||
|
dbQuery.$and = andConditions;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const members = await Member.find(dbQuery)
|
const members = await Member.find(dbQuery)
|
||||||
.select(
|
.select(
|
||||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
|
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
||||||
)
|
)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("offering")) filtered.offering = member.offering;
|
if (isVisible("offering")) filtered.offering = member.offering;
|
||||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||||
|
|
||||||
|
// Craft tags (with fallback to offering.tags for backward compat)
|
||||||
|
if (isVisible("craftTags")) {
|
||||||
|
filtered.craftTags = member.craftTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community connections (expose only public-safe fields)
|
||||||
|
if (isVisible("communityConnections")) {
|
||||||
|
filtered.communityConnections = {
|
||||||
|
topics: member.communityConnections?.topics,
|
||||||
|
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||||
|
availability: member.communityConnections?.availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Peer support: expose only fields needed for matching/contact UX
|
// Peer support: expose only fields needed for matching/contact UX
|
||||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||||
if (member.peerSupport?.enabled) {
|
if (member.peerSupport?.enabled) {
|
||||||
|
|
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
|
||||||
return filtered;
|
return filtered;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get unique tags for filter options (from both offering and lookingFor)
|
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
|
||||||
const allTags = members
|
const allTags = members
|
||||||
.flatMap((m) => [
|
.flatMap((m) => [
|
||||||
...(m.offering?.tags || []),
|
...(m.offering?.tags || []),
|
||||||
|
|
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
|
||||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
|
// Fetch predefined tags from Tag model for filter bars
|
||||||
|
const [craftTags, cooperativeTags] = await Promise.all([
|
||||||
|
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
|
||||||
|
Tag.find({ pool: "cooperative", active: true }).sort({ label: 1 }).lean(),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: filteredMembers,
|
members: filteredMembers,
|
||||||
totalCount: filteredMembers.length,
|
totalCount: filteredMembers.length,
|
||||||
filters: {
|
filters: {
|
||||||
availableSkills: allTags,
|
availableSkills: allTags,
|
||||||
availableTopics: allTopics,
|
availableTopics: allTopics,
|
||||||
|
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
|
||||||
|
cooperativeTags: cooperativeTags.map((t) => ({
|
||||||
|
slug: t.slug,
|
||||||
|
label: t.label,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
95
server/api/members/me/community-connections.patch.js
Normal file
95
server/api/members/me/community-connections.patch.js
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -33,6 +33,8 @@ export default defineEventHandler(async (event) => {
|
||||||
"socialLinksPrivacy",
|
"socialLinksPrivacy",
|
||||||
"offeringPrivacy",
|
"offeringPrivacy",
|
||||||
"lookingForPrivacy",
|
"lookingForPrivacy",
|
||||||
|
"craftTagsPrivacy",
|
||||||
|
"communityConnectionsPrivacy",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build update object from validated data
|
// 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)
|
// Handle offering and lookingFor separately (nested objects)
|
||||||
if (body.offering !== undefined) {
|
if (body.offering !== undefined) {
|
||||||
updateData.offering = {
|
updateData.offering = {
|
||||||
|
|
@ -102,6 +109,7 @@ export default defineEventHandler(async (event) => {
|
||||||
socialLinks: member.socialLinks,
|
socialLinks: member.socialLinks,
|
||||||
offering: member.offering,
|
offering: member.offering,
|
||||||
lookingFor: member.lookingFor,
|
lookingFor: member.lookingFor,
|
||||||
|
craftTags: member.craftTags,
|
||||||
showInDirectory: member.showInDirectory,
|
showInDirectory: member.showInDirectory,
|
||||||
notifications: member.notifications,
|
notifications: member.notifications,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
16
server/api/tags/index.get.js
Normal file
16
server/api/tags/index.get.js
Normal 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 }
|
||||||
|
})
|
||||||
17
server/api/tags/suggest.post.js
Normal file
17
server/api/tags/suggest.post.js
Normal 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 }
|
||||||
|
})
|
||||||
213
server/migrations/migrate-community-connections.js
Normal file
213
server/migrations/migrate-community-connections.js
Normal 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()
|
||||||
|
})
|
||||||
|
|
@ -17,7 +17,11 @@ const ACTIVITY_TYPES = [
|
||||||
'role_changed',
|
'role_changed',
|
||||||
'admin_profile_update',
|
'admin_profile_update',
|
||||||
'slack_invited',
|
'slack_invited',
|
||||||
'email_sent'
|
'email_sent',
|
||||||
|
'community_connections_updated',
|
||||||
|
'connection_requested',
|
||||||
|
'connection_confirmed',
|
||||||
|
'tag_suggested'
|
||||||
]
|
]
|
||||||
|
|
||||||
const activityLogSchema = new mongoose.Schema({
|
const activityLogSchema = new mongoose.Schema({
|
||||||
|
|
|
||||||
22
server/models/connection.js
Normal file
22
server/models/connection.js
Normal 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)
|
||||||
|
|
@ -90,6 +90,21 @@ const memberSchema = new mongoose.Schema({
|
||||||
slackDMChannelId: String, // DM channel ID for direct messaging
|
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 settings for profile fields
|
||||||
privacy: {
|
privacy: {
|
||||||
pronouns: {
|
pronouns: {
|
||||||
|
|
@ -137,12 +152,23 @@ const memberSchema = new mongoose.Schema({
|
||||||
enum: ["public", "members", "private"],
|
enum: ["public", "members", "private"],
|
||||||
default: "members",
|
default: "members",
|
||||||
},
|
},
|
||||||
|
craftTags: {
|
||||||
|
type: String,
|
||||||
|
enum: ["public", "members", "private"],
|
||||||
|
default: "members",
|
||||||
|
},
|
||||||
|
communityConnections: {
|
||||||
|
type: String,
|
||||||
|
enum: ["public", "members", "private"],
|
||||||
|
default: "members",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
events: { type: Boolean, default: true },
|
events: { type: Boolean, default: true },
|
||||||
updates: { type: Boolean, default: true },
|
updates: { type: Boolean, default: true },
|
||||||
peerRequests: { type: Boolean, default: true },
|
peerRequests: { type: Boolean, default: true },
|
||||||
|
connectionRequests: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
inviteEmailSent: { type: Boolean, default: false },
|
inviteEmailSent: { type: Boolean, default: false },
|
||||||
|
|
|
||||||
13
server/models/tag.js
Normal file
13
server/models/tag.js
Normal 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)
|
||||||
13
server/models/tagSuggestion.js
Normal file
13
server/models/tagSuggestion.js
Normal 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)
|
||||||
|
|
@ -17,7 +17,11 @@ export const ACTIVITY_TYPES = {
|
||||||
ROLE_CHANGED: 'role_changed',
|
ROLE_CHANGED: 'role_changed',
|
||||||
ADMIN_PROFILE_UPDATE: 'admin_profile_update',
|
ADMIN_PROFILE_UPDATE: 'admin_profile_update',
|
||||||
SLACK_INVITED: 'slack_invited',
|
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 = {
|
export const ACTIVITY_TYPE_DEFAULTS = {
|
||||||
|
|
@ -37,7 +41,11 @@ export const ACTIVITY_TYPE_DEFAULTS = {
|
||||||
role_changed: 'admin',
|
role_changed: 'admin',
|
||||||
admin_profile_update: 'admin',
|
admin_profile_update: 'admin',
|
||||||
slack_invited: 'admin',
|
slack_invited: 'admin',
|
||||||
email_sent: 'member'
|
email_sent: 'member',
|
||||||
|
community_connections_updated: 'member',
|
||||||
|
connection_requested: 'member',
|
||||||
|
connection_confirmed: 'member',
|
||||||
|
tag_suggested: 'member'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ export const memberProfileUpdateSchema = z.object({
|
||||||
notifications: z.object({
|
notifications: z.object({
|
||||||
events: z.boolean().optional(),
|
events: z.boolean().optional(),
|
||||||
updates: z.boolean().optional(),
|
updates: z.boolean().optional(),
|
||||||
peerRequests: z.boolean().optional()
|
peerRequests: z.boolean().optional(),
|
||||||
|
connectionRequests: z.boolean().optional()
|
||||||
}).optional(),
|
}).optional(),
|
||||||
pronounsPrivacy: privacyEnum.optional(),
|
pronounsPrivacy: privacyEnum.optional(),
|
||||||
timeZonePrivacy: privacyEnum.optional(),
|
timeZonePrivacy: privacyEnum.optional(),
|
||||||
|
|
@ -48,7 +49,10 @@ export const memberProfileUpdateSchema = z.object({
|
||||||
locationPrivacy: privacyEnum.optional(),
|
locationPrivacy: privacyEnum.optional(),
|
||||||
socialLinksPrivacy: privacyEnum.optional(),
|
socialLinksPrivacy: privacyEnum.optional(),
|
||||||
offeringPrivacy: 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({
|
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),
|
memberIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
emailTemplate: z.string().min(1).max(10000)
|
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()
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue