feat(admin): rename series route and add tags review page
Rename /admin/series-management to /admin/series so it follows the /admin/<section> convention; the breadcrumb's auto-derived parent link is now a real route (was a dead /admin/series link). Add an /admin/tags page to review pending TagSuggestions — list, approve (creates the Tag), reject — backed by new admin endpoints and a tagSuggestionReviewSchema. Resolves the dead /admin/tags alert link.
This commit is contained in:
parent
7beb86b430
commit
151481f1ec
9 changed files with 358 additions and 9 deletions
|
|
@ -44,7 +44,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
:class="{ active: route.path.includes('/admin/series') }"
|
:class="{ active: route.path.includes('/admin/series') }"
|
||||||
>
|
>
|
||||||
Series
|
Series
|
||||||
|
|
@ -66,6 +66,14 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/tags"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/site-content"
|
to="/admin/site-content"
|
||||||
|
|
@ -153,7 +161,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
:class="{ active: route.path.includes('/admin/series') }"
|
:class="{ active: route.path.includes('/admin/series') }"
|
||||||
@click="isMobileMenuOpen = false"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
|
|
@ -178,6 +186,15 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/tags"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/site-content"
|
to="/admin/site-content"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<NuxtLink to="/admin/series-management" class="back-link">← Series</NuxtLink>
|
<NuxtLink to="/admin/series" class="back-link">← Series</NuxtLink>
|
||||||
<h1>Create New Series</h1>
|
<h1>Create New Series</h1>
|
||||||
<p>Create a new event series to group related events together</p>
|
<p>Create a new event series to group related events together</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
class="btn"
|
class="btn"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -211,7 +211,7 @@ const createSeries = async (redirectAfter = true) => {
|
||||||
|
|
||||||
if (redirectAfter) {
|
if (redirectAfter) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/admin/series-management')
|
router.push('/admin/series')
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
205
app/pages/admin/tags/index.vue
Normal file
205
app/pages/admin/tags/index.vue
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
<template>
|
||||||
|
<div class="admin-tags">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Tags</h1>
|
||||||
|
<p>Review tag suggestions submitted by members. Approving a suggestion creates a tag in its pool.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestions-list">
|
||||||
|
<div v-if="!suggestions.length" class="empty-state">
|
||||||
|
<p>No pending tag suggestions.</p>
|
||||||
|
<p class="empty-hint">New suggestions from members will appear here for review.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="suggestions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Pool</th>
|
||||||
|
<th>Suggested by</th>
|
||||||
|
<th>Suggested</th>
|
||||||
|
<th class="actions-col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="suggestion in suggestions" :key="suggestion.id">
|
||||||
|
<td class="label-cell">{{ suggestion.label }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tag-pill">{{ poolLabel(suggestion.pool) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ suggestion.suggestedBy }}</td>
|
||||||
|
<td class="date-cell">{{ formatDate(suggestion.createdAt) }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button
|
||||||
|
class="link-btn"
|
||||||
|
:disabled="processingId === suggestion.id"
|
||||||
|
@click="review(suggestion, 'approve')"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="link-btn link-btn-danger"
|
||||||
|
:disabled="processingId === suggestion.id"
|
||||||
|
@click="review(suggestion, 'reject')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { data, refresh } = await useFetch('/api/admin/tag-suggestions')
|
||||||
|
|
||||||
|
const suggestions = computed(() => data.value?.suggestions || [])
|
||||||
|
|
||||||
|
const poolLabel = (pool) => (pool === 'cooperative' ? 'Cooperative' : 'Craft')
|
||||||
|
|
||||||
|
const formatDate = (value) =>
|
||||||
|
new Date(value).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
const processingId = ref(null)
|
||||||
|
|
||||||
|
const review = async (suggestion, action) => {
|
||||||
|
processingId.value = suggestion.id
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/tag-suggestions/${suggestion.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action },
|
||||||
|
})
|
||||||
|
toast.add({
|
||||||
|
title: action === 'approve' ? 'Tag approved' : 'Suggestion rejected',
|
||||||
|
description:
|
||||||
|
action === 'approve'
|
||||||
|
? `"${suggestion.label}" was added to the ${poolLabel(suggestion.pool)} pool.`
|
||||||
|
: `"${suggestion.label}" was rejected.`,
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Action failed',
|
||||||
|
description: error.data?.statusMessage || error.message,
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
processingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-list {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.suggestions-table th,
|
||||||
|
.suggestions-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.suggestions-table thead th {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: normal;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.suggestions-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.date-cell {
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,7 @@ import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
test.describe('Admin series management page', () => {
|
test.describe('Admin series management page', () => {
|
||||||
test('series list loads for admin', async ({ adminPage }) => {
|
test('series list loads for admin', async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/series-management')
|
await adminPage.goto('/admin/series')
|
||||||
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
|
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
|
|
@ -12,9 +12,9 @@ test.describe('Admin series management page', () => {
|
||||||
|
|
||||||
test.describe('Admin series access control', () => {
|
test.describe('Admin series access control', () => {
|
||||||
test('non-admin redirect', async ({ page }) => {
|
test('non-admin redirect', async ({ page }) => {
|
||||||
await page.goto('/admin/series-management')
|
await page.goto('/admin/series')
|
||||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||||
expect(page.url()).not.toContain('/admin/series-management')
|
expect(page.url()).not.toContain('/admin/series')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ test.describe('Admin series CRUD', () => {
|
||||||
|
|
||||||
await adminPage.getByRole('button', { name: 'Create Series' }).click()
|
await adminPage.getByRole('button', { name: 'Create Series' }).click()
|
||||||
|
|
||||||
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
|
await adminPage.waitForURL('**/admin/series', { timeout: 15000 })
|
||||||
|
|
||||||
const card = adminPage.locator('.series-card', { hasText: title })
|
const card = adminPage.locator('.series-card', { hasText: title })
|
||||||
await expect(card).toBeVisible({ timeout: 10000 })
|
await expect(card).toBeVisible({ timeout: 10000 })
|
||||||
|
|
|
||||||
57
e2e/admin-tags.spec.js
Normal file
57
e2e/admin-tags.spec.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
|
test.describe('Admin tags page', () => {
|
||||||
|
test('page loads for admin', async ({ adminPage }) => {
|
||||||
|
await adminPage.goto('/admin/tags')
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'Tags', level: 1 })).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approve and reject pending suggestions', async ({ adminPage }) => {
|
||||||
|
const suffix = Date.now().toString().slice(-6)
|
||||||
|
const approveLabel = `e2e-tag-approve-${suffix}`
|
||||||
|
const rejectLabel = `e2e-tag-reject-${suffix}`
|
||||||
|
|
||||||
|
// Load the page first so the csrf-token cookie is set, then seed two
|
||||||
|
// pending suggestions via the authed member endpoint (state-changing
|
||||||
|
// requests require the double-submit CSRF header — see middleware/01.csrf.js).
|
||||||
|
await adminPage.goto('/admin/tags')
|
||||||
|
const cookies = await adminPage.context().cookies()
|
||||||
|
const csrf = cookies.find((c) => c.name === 'csrf-token')?.value
|
||||||
|
|
||||||
|
await adminPage.request.post('/api/tags/suggest', {
|
||||||
|
headers: { 'x-csrf-token': csrf },
|
||||||
|
data: { label: approveLabel, pool: 'craft' },
|
||||||
|
})
|
||||||
|
await adminPage.request.post('/api/tags/suggest', {
|
||||||
|
headers: { 'x-csrf-token': csrf },
|
||||||
|
data: { label: rejectLabel, pool: 'cooperative' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await adminPage.reload()
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const approveRow = adminPage.locator('tr', { hasText: approveLabel })
|
||||||
|
await expect(approveRow).toBeVisible({ timeout: 10000 })
|
||||||
|
await approveRow.getByRole('button', { name: 'Approve' }).click()
|
||||||
|
await expect(adminPage.locator('tr', { hasText: approveLabel })).toHaveCount(0, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectRow = adminPage.locator('tr', { hasText: rejectLabel })
|
||||||
|
await expect(rejectRow).toBeVisible()
|
||||||
|
await rejectRow.getByRole('button', { name: 'Reject' }).click()
|
||||||
|
await expect(adminPage.locator('tr', { hasText: rejectLabel })).toHaveCount(0, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin tags access control', () => {
|
||||||
|
test('non-admin redirect', async ({ page }) => {
|
||||||
|
await page.goto('/admin/tags')
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||||
|
expect(page.url()).not.toContain('/admin/tags')
|
||||||
|
})
|
||||||
|
})
|
||||||
43
server/api/admin/tag-suggestions/[id].patch.js
Normal file
43
server/api/admin/tag-suggestions/[id].patch.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import TagSuggestion from '../../../models/tagSuggestion.js'
|
||||||
|
import Tag from '../../../models/tag.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../../utils/validateBody.js'
|
||||||
|
import { tagSuggestionReviewSchema } from '../../../utils/schemas.js'
|
||||||
|
|
||||||
|
const slugify = (s) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const { action } = await validateBody(event, tagSuggestionReviewSchema)
|
||||||
|
|
||||||
|
const suggestion = await TagSuggestion.findById(id).lean()
|
||||||
|
if (!suggestion) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Tag suggestion not found' })
|
||||||
|
}
|
||||||
|
if (suggestion.status !== 'pending') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Tag suggestion already reviewed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
const slug = slugify(suggestion.label)
|
||||||
|
if (slug && !(await Tag.findOne({ slug }))) {
|
||||||
|
await Tag.create({ slug, label: suggestion.label, pool: suggestion.pool, active: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = action === 'approve' ? 'approved' : 'rejected'
|
||||||
|
await TagSuggestion.findByIdAndUpdate(id, { status }, { runValidators: false })
|
||||||
|
|
||||||
|
return { suggestion: { id, status } }
|
||||||
|
})
|
||||||
23
server/api/admin/tag-suggestions/index.get.js
Normal file
23
server/api/admin/tag-suggestions/index.get.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import TagSuggestion from '../../../models/tagSuggestion.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const suggestions = await TagSuggestion.find({ status: 'pending' })
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.populate('suggestedBy', 'name')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: suggestions.map((s) => ({
|
||||||
|
id: String(s._id),
|
||||||
|
label: s.label,
|
||||||
|
pool: s.pool,
|
||||||
|
suggestedBy: s.suggestedBy?.name || 'Unknown',
|
||||||
|
createdAt: s.createdAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -388,6 +388,10 @@ export const adminTagCreateSchema = z.object({
|
||||||
pool: z.enum(['craft', 'cooperative'])
|
pool: z.enum(['craft', 'cooperative'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const tagSuggestionReviewSchema = z.object({
|
||||||
|
action: z.enum(['approve', 'reject'])
|
||||||
|
})
|
||||||
|
|
||||||
// --- Board post / channel schemas ---
|
// --- Board post / channel schemas ---
|
||||||
|
|
||||||
export const boardPostCreateSchema = z.object({
|
export const boardPostCreateSchema = z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue