Merge feature/community-connections into main

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

View file

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

View file

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

View file

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

View file

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