feat(board): rewrite board.vue with classifieds layout

This commit is contained in:
Jennie Robinson Faber 2026-04-14 17:11:45 +01:00
parent 4d9eb3c198
commit c06cdd71fd

View file

@ -1,127 +1,80 @@
<template> <template>
<PageShell title="Board" :subtitle="pageSubtitle"> <PageShell title="Board" :subtitle="pageSubtitle">
<!-- Action bar -->
<div class="action-bar">
<button type="button" class="new-post-btn" @click="openNewForm">
+ New Post
</button>
</div>
<!-- Tags Drawer Toggle --> <!-- Tags Drawer Toggle -->
<div v-if="boardTagOptions.length > 0" class="tags-drawer-toggle"> <div v-if="cooperativeTags.length > 0" class="tags-drawer-toggle">
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer"> <button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
Tags... Tags...
<span v-if="boardFilterTags.length > 0" class="tag-count-badge">{{ boardFilterTags.length }}</span> <span v-if="activeTagFilter" class="tag-count-badge">1</span>
</button> </button>
</div> </div>
<!-- Tags Drawer --> <!-- Tags Drawer -->
<div v-if="showTagsDrawer && boardTagOptions.length > 0" class="tags-drawer"> <div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer">
<div class="skills-bar"> <div class="skills-bar">
<span class="tag-label">Topics:</span> <span class="tag-label">Filter:</span>
<button <button
v-for="tag in visibleTagOptions" v-for="tag in visibleTagOptions"
:key="tag.slug" :key="tag.slug"
type="button" type="button"
class="skill-tag" class="skill-tag"
:class="{ active: boardFilterTags.includes(tag.slug) }" :class="{ active: activeTagFilter === tag.slug }"
@click="toggleTag(tag.slug)" @click="toggleTagFilter(tag.slug)"
> >
{{ tag.label }} {{ tag.label || tag.name }}
</button> </button>
<button <button
v-if="boardTagOptions.length > 10" v-if="cooperativeTags.length > 10"
type="button" type="button"
class="more-btn" class="more-btn"
@click="showAllTags = !showAllTags" @click="showAllTags = !showAllTags"
> >
{{ showAllTags ? 'Show less' : `+${boardTagOptions.length - 10} more` }} {{ showAllTags ? 'Show less' : `+${cooperativeTags.length - 10} more` }}
</button> </button>
</div> </div>
</div> </div>
<!-- Board Content --> <!-- Inline form -->
<div v-if="showForm" class="form-wrapper">
<BoardPostForm
:post="editingPost"
:tags="cooperativeTags"
@submit="handleSubmit"
@cancel="closeForm"
/>
</div>
<!-- Content -->
<ClientOnly> <ClientOnly>
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<p>Loading board...</p> <p>Loading board...</p>
</div> </div>
<template v-else> <template v-else>
<!-- No topics empty state --> <div v-if="posts.length === 0" class="empty-state">
<div v-if="hasNoTopics" class="empty-state"> <p class="empty-title">No posts yet.</p>
<p class="empty-title">No topics yet</p> <p class="empty-sub">Be the first to post.</p>
<p class="empty-sub"> <button type="button" class="new-post-btn" @click="openNewForm">
<NuxtLink to="/member/profile">Add topics to your profile</NuxtLink> to find connections. + New Post
</p> </button>
</div> </div>
<!-- Suggestions grid --> <div v-else class="post-grid">
<div v-else-if="filteredSuggestions.length > 0" class="member-grid"> <BoardPostCard
<div v-for="post in posts"
v-for="suggestion in filteredSuggestions" :key="post._id"
:key="suggestion.member._id" :post="post"
class="member-card board-card" :channels="channels"
> :editable="isAuthor(post)"
<div class="mc-head"> @edit="handleEdit"
<div class="mc-avatar"> @delete="handleDelete"
<img />
v-if="suggestion.member.avatar"
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
:alt="suggestion.member.name"
class="mc-avatar-img"
/>
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
</div>
<div class="mc-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>
<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>
<span v-if="suggestion.member.craftTags.length > 5" class="tag-overflow">+{{ suggestion.member.craftTags.length - 5 }}</span>
</div>
<div class="cc-matches">
<div
v-for="match in suggestion.matchingTags"
:key="match.tagSlug"
class="match-row"
>
<span class="match-tag">{{ boardTagLabel(match.tagSlug) }}</span>
<span class="match-states">
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
<span class="match-sep">&middot;</span>
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
</span>
</div>
</div>
<div v-if="suggestion.member.slackHandle" class="cc-contact">
<span class="cc-slack">@{{ suggestion.member.slackHandle }}</span>
<button
type="button"
class="text-action"
@click="copyHandle(suggestion.member.slackHandle)"
>
{{ copiedHandle === suggestion.member.slackHandle ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
</div>
<!-- No matches -->
<div v-else class="empty-state">
<p class="empty-title">No matches yet</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>
</template> </template>
@ -138,176 +91,122 @@
definePageMeta({ middleware: ['members-auth'] }) definePageMeta({ middleware: ['members-auth'] })
const { memberData } = useAuth() const { memberData } = useAuth()
const { getSuggestions } = useBoard() const { posts, loading, fetchPosts, createPost, updatePost, deletePost } = useBoardPosts()
const { trackGoal, isComplete } = useOnboarding() const { channels, fetchChannels } = useBoardChannels()
// ---- State ---- const cooperativeTags = ref([])
const suggestions = ref([])
const loading = ref(false)
const boardTagOptions = ref([])
const boardFilterTags = ref([])
const copiedHandle = ref(null)
const showAllTags = ref(false)
const showTagsDrawer = ref(false) const showTagsDrawer = ref(false)
const craftTagOptions = ref([]) const showAllTags = ref(false)
const cooperativeTagOptions = ref([]) const activeTagFilter = ref(null)
// ---- Helpers ---- const showForm = ref(false)
const stateLabels = { const editingPost = ref(null)
help: 'Can help',
interested: 'Interested',
seeking: 'Need help',
}
const stateLabel = (state) => stateLabels[state] || state || ''
const craftTagLabel = (slug) => { const currentMemberId = computed(() => memberData.value?._id || null)
const found = craftTagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const boardTagLabel = (slug) => {
const found = boardTagOptions.value.find((t) => t.slug === slug)
if (found) return found.label
const fallback = cooperativeTagOptions.value.find((t) => t.slug === slug)
return fallback ? fallback.label : slug
}
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('-')
}
// ---- Computed ----
const visibleTagOptions = computed(() =>
showAllTags.value ? boardTagOptions.value : boardTagOptions.value.slice(0, 10)
)
const hasNoTopics = computed(() => {
if (!memberData.value) return false
const topics = memberData.value?.board?.topics
return !topics || topics.length === 0
})
const filteredSuggestions = computed(() => {
if (boardFilterTags.value.length === 0) return suggestions.value
return suggestions.value.filter((s) =>
s.matchingTags.some((m) => boardFilterTags.value.includes(m.tagSlug))
)
})
const pageSubtitle = computed(() => { const pageSubtitle = computed(() => {
const count = filteredSuggestions.value.length const count = posts.value.length
return `${count} connection${count === 1 ? '' : 's'}` return `${count} post${count === 1 ? '' : 's'}`
}) })
// ---- Tag toggle ---- const visibleTagOptions = computed(() =>
const toggleTag = (slug) => { showAllTags.value ? cooperativeTags.value : cooperativeTags.value.slice(0, 10)
const idx = boardFilterTags.value.indexOf(slug) )
if (idx > -1) {
boardFilterTags.value.splice(idx, 1) const isAuthor = (post) => {
if (!currentMemberId.value || !post.author) return false
const authorId = typeof post.author === 'object' ? post.author._id : post.author
return String(authorId) === String(currentMemberId.value)
}
const toggleTagFilter = async (slug) => {
activeTagFilter.value = activeTagFilter.value === slug ? null : slug
await fetchPosts(activeTagFilter.value ? { tag: activeTagFilter.value } : {})
}
const openNewForm = () => {
editingPost.value = null
showForm.value = true
}
const closeForm = () => {
showForm.value = false
editingPost.value = null
}
const handleEdit = (post) => {
editingPost.value = post
showForm.value = true
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const handleDelete = async (post) => {
if (typeof window === 'undefined') return
const ok = window.confirm('Delete this post? This cannot be undone.')
if (!ok) return
await deletePost(post._id)
}
const handleSubmit = async (body) => {
if (editingPost.value) {
await updatePost(editingPost.value._id, body)
} else { } else {
boardFilterTags.value.push(slug) await createPost(body)
} }
closeForm()
} }
// ---- Load tags ---- const loadTags = async () => {
const loadTagOptions = async () => { const data = await $fetch('/api/tags')
try { cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
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 }))
cooperativeTagOptions.value = tags
.filter((t) => t.pool === 'cooperative')
.map((t) => ({ slug: t.slug, label: t.label }))
} catch (error) {
console.error('Failed to load tags:', error)
}
} }
// ---- Load suggestions ----
const loadBoard = async () => {
loading.value = true
try {
const data = await getSuggestions()
suggestions.value = data.suggestions || []
// Build board tag options from user's own topics
const allCoopTags = cooperativeTagOptions.value
const myTopicSlugs = (memberData.value?.board?.topics || []).map((t) => t.tagSlug)
boardTagOptions.value = myTopicSlugs.length
? allCoopTags.filter((t) => myTopicSlugs.includes(t.slug))
: allCoopTags
} catch (error) {
console.error('Failed to load board:', error)
suggestions.value = []
} finally {
loading.value = false
}
}
// ---- Clipboard ----
let copyTimer = null
const copyHandle = async (handle) => {
try {
await navigator.clipboard.writeText(`@${handle}`)
copiedHandle.value = handle
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => {
copiedHandle.value = null
copyTimer = null
}, 1500)
} catch (error) {
console.error('Clipboard write failed:', error)
}
}
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
})
// ---- Head ----
useHead({ useHead({
title: 'Board - Ghost Guild', title: 'Board - Ghost Guild',
meta: [ meta: [
{ {
name: 'description', name: 'description',
content: 'Find Ghost Guild members who share your cooperative interests and reach out on Slack.', content: 'Share what you are seeking and offering with the Ghost Guild community.',
}, },
], ],
}) })
// ---- Init ----
onMounted(async () => { onMounted(async () => {
if (!isComplete.value) { await Promise.all([loadTags(), fetchPosts(), fetchChannels()])
trackGoal('boardPageVisited')
}
await loadTagOptions()
await loadBoard()
}) })
</script> </script>
<style scoped> <style scoped>
.action-bar {
padding: 12px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
justify-content: flex-end;
}
.new-post-btn {
font-family: "Commit Mono", monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--candle);
background: transparent;
border: 1px dashed var(--candle-faint);
padding: 4px 12px;
cursor: pointer;
transition: all 0.15s;
}
.new-post-btn:hover {
border-style: solid;
background: rgba(154, 116, 32, 0.08);
}
/* ---- TAGS DRAWER ---- */ /* ---- TAGS DRAWER ---- */
.tags-drawer-toggle { .tags-drawer-toggle {
padding: 8px 24px; padding: 8px 24px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.drawer-btn { .drawer-btn {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
@ -325,7 +224,6 @@ onMounted(async () => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
color: var(--text); color: var(--text);
} }
.tag-count-badge { .tag-count-badge {
font-size: 9px; font-size: 9px;
background: var(--candle-faint); background: var(--candle-faint);
@ -334,11 +232,9 @@ onMounted(async () => {
min-width: 14px; min-width: 14px;
text-align: center; text-align: center;
} }
.tags-drawer { .tags-drawer {
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.skills-bar { .skills-bar {
padding: 12px 24px; padding: 12px 24px;
display: flex; display: flex;
@ -346,7 +242,6 @@ onMounted(async () => {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.skills-bar .tag-label { .skills-bar .tag-label {
font-size: 10px; font-size: 10px;
color: var(--text-faint); color: var(--text-faint);
@ -354,7 +249,6 @@ onMounted(async () => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
.skills-bar .skill-tag { .skills-bar .skill-tag {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 10px; font-size: 10px;
@ -376,7 +270,6 @@ onMounted(async () => {
color: var(--candle); color: var(--candle);
background: rgba(154, 116, 32, 0.08); background: rgba(154, 116, 32, 0.08);
} }
.more-btn { .more-btn {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 10px; font-size: 10px;
@ -390,183 +283,34 @@ onMounted(async () => {
text-decoration: underline; text-decoration: underline;
} }
/* ---- LOADING ---- */ /* ---- FORM WRAPPER ---- */
.form-wrapper {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
/* ---- POST GRID ---- */
.post-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 20px 24px;
}
/* ---- LOADING / EMPTY ---- */
.loading-state { .loading-state {
padding: 60px 24px; padding: 60px 24px;
text-align: center; text-align: center;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px; font-size: 12px;
} }
/* ---- MEMBER GRID ---- */
.member-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.member-card {
padding: 16px 20px;
border-bottom: 1px dashed var(--border);
border-right: 1px dashed var(--border);
transition: background 0.15s;
}
.member-card:hover {
background: var(--surface);
}
.member-card:nth-child(2n) {
border-right: none;
}
/* ---- CARD LAYOUT ---- */
.mc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.mc-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;
}
.mc-avatar-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.mc-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-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;
}
.tag-overflow {
font-size: 10px;
color: var(--text-faint);
}
.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;
}
.cc-contact {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.cc-slack {
font-size: 11px;
color: var(--candle-dim);
font-family: "Commit Mono", monospace;
}
.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;
}
/* ---- EMPTY STATE ---- */
.empty-state { .empty-state {
padding: 60px 24px; padding: 60px 24px;
text-align: center; text-align: center;
} }
.empty-title { .empty-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 18px; font-size: 20px;
color: var(--text-dim); color: var(--text-dim);
margin-bottom: 6px; margin-bottom: 6px;
} }
@ -575,29 +319,23 @@ onMounted(async () => {
color: var(--text-faint); color: var(--text-faint);
margin-bottom: 16px; margin-bottom: 16px;
} }
.empty-sub a {
color: var(--candle);
}
/* ---- RESPONSIVE ---- */ /* ---- RESPONSIVE ---- */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.member-grid { .post-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.member-card {
border-right: none;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.skills-bar {
padding: 10px 20px;
}
.tags-drawer-toggle { .tags-drawer-toggle {
padding: 8px 20px; padding: 8px 20px;
} }
.member-card { .skills-bar {
padding: 14px 16px; padding: 10px 20px;
}
.post-grid,
.form-wrapper {
padding: 16px;
} }
} }
</style> </style>