feat(board): admin page for managing board channels

This commit is contained in:
Jennie Robinson Faber 2026-04-14 17:27:06 +01:00
parent 4b3ba411dd
commit 7707068f36
2 changed files with 557 additions and 0 deletions

View file

@ -0,0 +1,540 @@
<template>
<div class="admin-board-channels">
<!-- Page Header -->
<div class="page-header">
<div class="header-row">
<div>
<h1>Board Channels</h1>
<p>Map cooperative tags to Slack channels for board cross-posting</p>
</div>
<div class="header-actions">
<button class="btn btn-primary" @click="openCreateModal">+ New Channel</button>
</div>
</div>
</div>
<!-- Unmapped Tags Indicator -->
<div v-if="unmappedTags.length > 0" class="unmapped-block">
<div class="section-label">Unmapped Cooperative Tags</div>
<p class="unmapped-hint">These cooperative tags are not yet mapped to any board channel:</p>
<div class="tag-pills">
<span v-for="tag in unmappedTags" :key="tag.slug" class="tag-pill tag-pill-warning">
{{ tag.label }}
</span>
</div>
</div>
<!-- Channels List -->
<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>
</div>
<table v-else class="channels-table">
<thead>
<tr>
<th>Name</th>
<th>Slack Channel ID</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>
<div class="tag-pills">
<span
v-for="slug in channel.tagSlugs || []"
:key="slug"
class="tag-pill"
>
{{ tagLabel(slug) }}
</span>
<span v-if="!(channel.tagSlugs || []).length" class="tag-empty"></span>
</div>
</td>
<td class="actions-cell">
<button class="link-btn" @click="openEditModal(channel)">Edit</button>
<button class="link-btn link-btn-danger" @click="deleteChannel(channel)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create / Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingId ? 'Edit Channel' : 'New Channel' }}</h2>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="formData.name" type="text" placeholder="e.g., #coop-formation" />
</div>
<div 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>
</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
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>
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="saveChannel">
{{ saving ? 'Saving...' : (editingId ? 'Save Changes' : 'Create Channel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const toast = useToast()
const { channels, fetchChannels } = useBoardChannels()
const { data: tagsData } = await useFetch('/api/tags')
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === 'cooperative'),
)
const tagLabelMap = computed(() => {
const map = {}
for (const tag of tagsData.value?.tags || []) {
map[tag.slug] = tag.label
}
return map
})
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
const mappedSlugs = computed(() => {
const set = new Set()
for (const ch of channels.value) {
for (const slug of ch.tagSlugs || []) set.add(slug)
}
return set
})
const unmappedTags = computed(() =>
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
)
// ---- Modal State ----
const showModal = ref(false)
const editingId = ref(null)
const saving = ref(false)
const formData = reactive({
name: '',
slackChannelId: '',
tagSlugs: [],
})
const resetForm = () => {
formData.name = ''
formData.slackChannelId = ''
formData.tagSlugs = []
}
const openCreateModal = () => {
editingId.value = null
resetForm()
showModal.value = true
}
const openEditModal = (channel) => {
editingId.value = channel._id
formData.name = channel.name || ''
formData.slackChannelId = channel.slackChannelId || ''
formData.tagSlugs = [...(channel.tagSlugs || [])]
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingId.value = null
resetForm()
}
const toggleTag = (slug) => {
const idx = formData.tagSlugs.indexOf(slug)
if (idx === -1) formData.tagSlugs.push(slug)
else formData.tagSlugs.splice(idx, 1)
}
const saveChannel = async () => {
if (!formData.name.trim() || !formData.slackChannelId.trim()) {
toast.add({
title: 'Missing fields',
description: 'Name and Slack channel ID are required.',
color: 'red',
})
return
}
saving.value = true
try {
const body = {
name: formData.name.trim(),
slackChannelId: formData.slackChannelId.trim(),
tagSlugs: formData.tagSlugs,
}
if (editingId.value) {
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
method: 'PATCH',
body,
})
toast.add({ title: 'Channel updated', color: 'green' })
} else {
await $fetch('/api/admin/board-channels', {
method: 'POST',
body,
})
toast.add({ title: 'Channel created', color: 'green' })
}
await fetchChannels()
closeModal()
} catch (err) {
toast.add({
title: 'Save failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
saving.value = false
}
}
const deleteChannel = async (channel) => {
if (!window.confirm(`Delete channel "${channel.name}"? This cannot be undone.`)) return
try {
await $fetch(`/api/admin/board-channels/${channel._id}`, { method: 'DELETE' })
toast.add({ title: 'Channel deleted', color: 'green' })
await fetchChannels()
} catch (err) {
toast.add({
title: 'Delete failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
onMounted(() => {
fetchChannels()
})
</script>
<style scoped>
.admin-board-channels {
padding: 24px;
max-width: 1100px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--border);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
margin-bottom: 4px;
}
.page-header p {
color: var(--text-dim);
font-size: 13px;
}
.header-actions {
display: flex;
gap: 8px;
}
/* ---- Unmapped Indicator ---- */
.unmapped-block {
border: 1px dashed var(--border);
padding: 16px;
margin-bottom: 24px;
background: var(--surface);
}
.unmapped-hint {
font-size: 12px;
color: var(--text-dim);
margin: 4px 0 12px;
}
.section-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
/* ---- Tag Pills ---- */
.tag-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
}
.tag-pill-warning {
border-color: var(--ember);
color: var(--ember);
}
.tag-empty {
color: var(--text-faint);
font-size: 12px;
}
/* ---- Table ---- */
.channels-list {
border: 1px dashed var(--border);
background: var(--bg);
}
.channels-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.channels-table th,
.channels-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px dashed var(--border);
vertical-align: top;
}
.channels-table thead th {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
background: var(--surface);
}
.channels-table tbody tr:last-child td {
border-bottom: none;
}
.name-cell {
font-weight: 600;
}
.mono {
font-family: 'Commit Mono', monospace;
font-size: 12px;
color: var(--text-dim);
}
.actions-col {
width: 160px;
}
.actions-cell {
white-space: nowrap;
}
.link-btn {
background: none;
border: none;
color: var(--candle-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
text-decoration: underline;
}
.link-btn:hover {
color: var(--candle);
}
.link-btn-danger {
color: var(--ember);
}
.link-btn-danger:hover {
color: var(--ember);
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-dim);
}
.empty-hint {
font-size: 12px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- Modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 20px;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-dim);
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px dashed var(--border);
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 6px;
}
.field input {
width: 100%;
padding: 8px 10px;
background: var(--input-bg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus {
outline: none;
border-color: var(--candle);
}
.help-text {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
.tag-select {
border: 1px dashed var(--border);
padding: 10px;
max-height: 200px;
overflow-y: auto;
background: var(--input-bg);
}
.tag-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
cursor: pointer;
}
.tag-checkbox input {
margin: 0;
}
</style>