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"
|
||||
: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>
|
||||
|
|
|
|||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue