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
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="create-form">
|
||||
<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>
|
||||
<p>Create a new event series to group related events together</p>
|
||||
</div>
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
to="/admin/series"
|
||||
class="btn"
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -211,7 +211,7 @@ const createSeries = async (redirectAfter = true) => {
|
|||
|
||||
if (redirectAfter) {
|
||||
setTimeout(() => {
|
||||
router.push('/admin/series-management')
|
||||
router.push('/admin/series')
|
||||
}, 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue