Adds AdminGhost bot token for admin-only Slack channel creation, refreshes BoardPostCard/Form layouts, and expands admin board-channels management.
596 lines
14 KiB
Vue
596 lines
14 KiB
Vue
<template>
|
|
<div class="admin-board-channels">
|
|
<!-- Page Header -->
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<div>
|
|
<h1>Board Channels</h1>
|
|
<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>
|
|
</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 create your first board channel in Slack.</p>
|
|
</div>
|
|
|
|
<table v-else class="channels-table">
|
|
<thead>
|
|
<tr>
|
|
<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">
|
|
<div class="channel-name">{{ channel.name }}</div>
|
|
<div class="channel-id">{{ channel.slackChannelId }}</div>
|
|
</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">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<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 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).</p>
|
|
</div>
|
|
<div class="field">
|
|
<label>Mapped Tags</label>
|
|
<p class="help-text">Cooperative tags that route posts to this channel.</p>
|
|
<div class="pill-grid">
|
|
<button
|
|
v-for="tag in cooperativeTags"
|
|
:key="tag.slug"
|
|
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">
|
|
<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
|
|
})
|
|
|
|
// 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)),
|
|
)
|
|
|
|
// ---- 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()) {
|
|
toast.add({
|
|
title: 'Missing fields',
|
|
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
|
|
}
|
|
|
|
saving.value = true
|
|
try {
|
|
const body = {
|
|
name: formData.name.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',
|
|
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 9px;
|
|
font-size: 11px;
|
|
font-family: "Commit Mono", monospace;
|
|
background: transparent;
|
|
border: 1px dashed var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.channel-name {
|
|
font-weight: 600;
|
|
}
|
|
.channel-id {
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.pill-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
}
|
|
.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>
|