feat(board): redesign classifieds + Slack channel creation

Adds AdminGhost bot token for admin-only Slack channel creation, refreshes
BoardPostCard/Form layouts, and expands admin board-channels management.
This commit is contained in:
Jennie Robinson Faber 2026-04-14 20:20:17 +01:00
parent 6f3d088763
commit 9a560f2a3b
14 changed files with 544 additions and 158 deletions

View file

@ -5,7 +5,7 @@
<div class="header-row">
<div>
<h1>Board Channels</h1>
<p>Map cooperative tags to Slack channels for board cross-posting</p>
<p>Create Slack channels for cooperative tags. New channels are created in Slack when you click Create Channel.</p>
</div>
<div class="header-actions">
<button class="btn btn-primary" @click="openCreateModal">+ New Channel</button>
@ -28,22 +28,23 @@
<div class="channels-list">
<div v-if="!channels.length" class="empty-state">
<p>No board channels configured yet.</p>
<p class="empty-hint">Click "+ New Channel" to map your first tag to a Slack channel.</p>
<p class="empty-hint">Click "+ New Channel" to create your first board channel in Slack.</p>
</div>
<table v-else class="channels-table">
<thead>
<tr>
<th>Name</th>
<th>Slack Channel ID</th>
<th>Channel</th>
<th>Mapped Tags</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="channel in channels" :key="channel._id">
<td class="name-cell">{{ channel.name }}</td>
<td class="mono">{{ channel.slackChannelId }}</td>
<td class="name-cell">
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-id">{{ channel.slackChannelId }}</div>
</td>
<td>
<div class="tag-pills">
<span
@ -75,32 +76,39 @@
<div class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="formData.name" type="text" placeholder="e.g., #coop-formation" />
<input v-model="formData.name" type="text" placeholder="e.g., coop-formation" />
<p v-if="!editingId" class="help-text">A new Slack channel will be created with this name. Lowercase, letters/numbers/dashes only.</p>
</div>
<div class="field">
<div v-if="editingId" class="field">
<label>Slack Channel ID</label>
<input v-model="formData.slackChannelId" type="text" placeholder="C0123456789" />
<p class="help-text">The Slack channel ID (starts with C). Find it in channel settings.</p>
<p class="help-text">The Slack channel ID (starts with C).</p>
</div>
<div class="field">
<label>Mapped Tags</label>
<p class="help-text">Cooperative tags that route posts to this channel.</p>
<div class="tag-select">
<label
<div class="pill-grid">
<button
v-for="tag in cooperativeTags"
:key="tag.slug"
class="tag-checkbox"
>
<input
type="checkbox"
:value="tag.slug"
:checked="formData.tagSlugs.includes(tag.slug)"
@change="toggleTag(tag.slug)"
/>
<span>{{ tag.label }}</span>
</label>
type="button"
class="pill"
:class="{
selected: formData.tagSlugs.includes(tag.slug),
disabled: tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug),
}"
:disabled="!!(tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug))"
:title="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)
? `Already mapped to ${tagOwner(tag.slug)}`
: ''"
@click="toggleTag(tag.slug)"
>{{ tag.label }}<span
v-if="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)"
class="pill-owner"
> · {{ tagOwner(tag.slug) }}</span></button>
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
</div>
<p class="help-text">Each tag can only be mapped to one channel.</p>
</div>
</div>
<div class="modal-actions">
@ -148,6 +156,18 @@ const mappedSlugs = computed(() => {
return set
})
// Map of slug -> channel name, EXCLUDING the channel currently being edited.
const otherChannelTagMap = computed(() => {
const map = {}
for (const ch of channels.value) {
if (editingId.value && String(ch._id) === String(editingId.value)) continue
for (const slug of ch.tagSlugs || []) map[slug] = ch.name
}
return map
})
const tagOwner = (slug) => otherChannelTagMap.value[slug] || ''
const unmappedTags = computed(() =>
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
)
@ -195,10 +215,18 @@ const toggleTag = (slug) => {
}
const saveChannel = async () => {
if (!formData.name.trim() || !formData.slackChannelId.trim()) {
if (!formData.name.trim()) {
toast.add({
title: 'Missing fields',
description: 'Name and Slack channel ID are required.',
description: 'Name is required.',
color: 'red',
})
return
}
if (editingId.value && !formData.slackChannelId.trim()) {
toast.add({
title: 'Missing fields',
description: 'Slack channel ID is required.',
color: 'red',
})
return
@ -208,9 +236,11 @@ const saveChannel = async () => {
try {
const body = {
name: formData.name.trim(),
slackChannelId: formData.slackChannelId.trim(),
tagSlugs: formData.tagSlugs,
}
if (formData.slackChannelId.trim()) {
body.slackChannelId = formData.slackChannelId.trim()
}
if (editingId.value) {
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
method: 'PATCH',
@ -323,11 +353,12 @@ onMounted(() => {
.tag-pill {
display: inline-block;
padding: 2px 8px;
padding: 2px 9px;
font-size: 11px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: "Commit Mono", monospace;
background: transparent;
border: 1px dashed var(--border);
color: var(--text-dim);
}
.tag-pill-warning {
@ -373,14 +404,14 @@ onMounted(() => {
border-bottom: none;
}
.name-cell {
.channel-name {
font-weight: 600;
}
.mono {
.channel-id {
font-family: 'Commit Mono', monospace;
font-size: 12px;
color: var(--text-dim);
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.actions-col {
@ -517,24 +548,49 @@ onMounted(() => {
margin-top: 4px;
}
.tag-select {
border: 1px dashed var(--border);
padding: 10px;
max-height: 200px;
overflow-y: auto;
background: var(--input-bg);
}
.tag-checkbox {
.pill-grid {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
cursor: pointer;
flex-wrap: wrap;
gap: 4px;
max-height: 240px;
overflow-y: auto;
}
.tag-checkbox input {
margin: 0;
.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;
}
.pill.disabled,
.pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pill.disabled:hover {
color: var(--text-faint);
border-color: var(--border);
}
.pill-owner {
font-size: 10px;
color: var(--text-faint);
margin-left: 2px;
}
</style>