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:
parent
6f3d088763
commit
9a560f2a3b
14 changed files with 544 additions and 158 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue