@commonpub/layer 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ name: string;
4
+ slug: string;
5
+ color?: string | null;
6
+ icon?: string | null;
7
+ }>();
8
+
9
+ const badgeColor = computed(() => props.color || 'var(--text-dim)');
10
+ </script>
11
+
12
+ <template>
13
+ <span class="cpub-category-badge" :style="{ '--cat-color': badgeColor }">
14
+ <i v-if="icon" :class="icon" class="cpub-category-badge-icon"></i>
15
+ {{ name }}
16
+ </span>
17
+ </template>
18
+
19
+ <style scoped>
20
+ .cpub-category-badge {
21
+ font-family: var(--font-mono);
22
+ font-size: 9px;
23
+ font-weight: 700;
24
+ letter-spacing: 0.06em;
25
+ text-transform: uppercase;
26
+ padding: 2px 7px;
27
+ display: inline-flex;
28
+ align-items: center;
29
+ gap: 4px;
30
+ background: var(--color-badge-overlay, rgba(0, 0, 0, 0.75));
31
+ color: var(--cat-color, var(--text-dim));
32
+ backdrop-filter: blur(4px);
33
+ }
34
+
35
+ .cpub-category-badge-icon {
36
+ font-size: 8px;
37
+ }
38
+ </style>
@@ -12,7 +12,15 @@ import type { Serialized, ContentListItem } from '@commonpub/server';
12
12
  * - Stats: hearts, views, comments
13
13
  */
14
14
  const props = defineProps<{
15
- item: Serialized<ContentListItem> & { isFeatured?: boolean };
15
+ item: Serialized<ContentListItem> & {
16
+ isFeatured?: boolean;
17
+ isEditorial?: boolean;
18
+ editorialNote?: string | null;
19
+ categoryName?: string | null;
20
+ categorySlug?: string | null;
21
+ categoryColor?: string | null;
22
+ categoryIcon?: string | null;
23
+ };
16
24
  }>();
17
25
 
18
26
  const cover = computed(() => {
@@ -80,9 +88,17 @@ function formatCount(n: number | undefined): string {
80
88
 
81
89
  <!-- Badges overlay -->
82
90
  <div class="cpub-cc-badges">
83
- <span v-if="item.isFeatured" class="cpub-cc-badge cpub-cc-badge--featured">
91
+ <EditorialBadge v-if="item.isEditorial" :note="item.editorialNote" />
92
+ <span v-if="item.isFeatured && !item.isEditorial" class="cpub-cc-badge cpub-cc-badge--featured">
84
93
  <i class="fa-solid fa-star"></i> Featured
85
94
  </span>
95
+ <CategoryBadge
96
+ v-if="item.categoryName && item.categorySlug"
97
+ :name="item.categoryName"
98
+ :slug="item.categorySlug"
99
+ :color="item.categoryColor"
100
+ :icon="item.categoryIcon"
101
+ />
86
102
  <span v-if="isFederated && item.sourceDomain" class="cpub-cc-badge cpub-cc-badge--federated">
87
103
  <i class="fa-solid fa-globe" /> {{ item.sourceDomain }}
88
104
  </span>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ note?: string | null;
4
+ }>();
5
+ </script>
6
+
7
+ <template>
8
+ <span class="cpub-cc-badge cpub-cc-badge--editorial" :title="note || 'Staff Pick'">
9
+ <i class="fa-solid fa-pen-fancy"></i> Staff Pick
10
+ </span>
11
+ </template>
12
+
13
+ <style scoped>
14
+ .cpub-cc-badge--editorial {
15
+ font-family: var(--font-mono);
16
+ font-size: 9px;
17
+ font-weight: 700;
18
+ letter-spacing: 0.06em;
19
+ text-transform: uppercase;
20
+ padding: 2px 7px;
21
+ display: inline-flex;
22
+ align-items: center;
23
+ gap: 4px;
24
+ background: var(--color-badge-overlay, rgba(0, 0, 0, 0.75));
25
+ color: var(--teal);
26
+ backdrop-filter: blur(4px);
27
+ }
28
+ </style>
@@ -24,6 +24,7 @@ const activeTab = defineModel<string>('activeTab', { required: true });
24
24
  >
25
25
  <i :class="tab.icon" style="font-size: 10px"></i>
26
26
  {{ tab.label }}
27
+ <span v-if="tab.count" class="cpub-tab-count">{{ tab.count }}</span>
27
28
  </button>
28
29
  </div>
29
30
  </div>
@@ -78,6 +79,22 @@ const activeTab = defineModel<string>('activeTab', { required: true });
78
79
  .cpub-tab-btn:hover { color: var(--text); }
79
80
  .cpub-tab-btn.active { color: var(--accent); border-bottom-color: var(--accent-border); font-weight: 600; }
80
81
 
82
+ .cpub-tab-count {
83
+ font-size: 10px;
84
+ font-family: var(--font-mono);
85
+ background: var(--surface2);
86
+ color: var(--text-faint);
87
+ padding: 1px 6px;
88
+ border-radius: 10px;
89
+ min-width: 18px;
90
+ text-align: center;
91
+ }
92
+
93
+ .cpub-tab-btn.active .cpub-tab-count {
94
+ background: var(--accent-bg);
95
+ color: var(--accent);
96
+ }
97
+
81
98
  .cpub-hub-main {
82
99
  max-width: 1200px;
83
100
  margin: 0 auto;
@@ -1,6 +1,4 @@
1
1
  <script setup lang="ts">
2
- import { createProductSchema } from '@commonpub/schema';
3
-
4
2
  const props = defineProps<{
5
3
  products: { items: Array<{ id: string; name: string; description: string | null; imageUrl: string | null; category: string | null; status: string }>; total: number } | null;
6
4
  currentUserRole?: string | null;
@@ -48,13 +46,15 @@ async function handleCreate(): Promise<void> {
48
46
  <form v-if="showForm" class="cpub-resource-form" @submit.prevent="handleCreate">
49
47
  <input v-model="formName" type="text" placeholder="Product name" class="cpub-input" required />
50
48
  <input v-model="formDescription" type="text" placeholder="Short description (optional)" class="cpub-input" />
51
- <select v-model="formCategory" class="cpub-input">
49
+ <select v-model="formCategory" class="cpub-input" aria-label="Product category">
52
50
  <option value="microcontroller">Microcontroller</option>
53
51
  <option value="sbc">SBC</option>
54
52
  <option value="sensor">Sensor</option>
53
+ <option value="actuator">Actuator</option>
55
54
  <option value="display">Display</option>
56
55
  <option value="communication">Communication</option>
57
56
  <option value="power">Power</option>
57
+ <option value="mechanical">Mechanical</option>
58
58
  <option value="software">Software</option>
59
59
  <option value="tool">Tool</option>
60
60
  <option value="other">Other</option>
@@ -89,6 +89,18 @@ async function handleCreate(): Promise<void> {
89
89
  </template>
90
90
 
91
91
  <style scoped>
92
+ .cpub-products-header { margin-bottom: 16px; }
93
+
94
+ .cpub-resource-form {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 10px;
98
+ padding: 16px;
99
+ background: var(--surface);
100
+ border: var(--border-width-default) solid var(--border);
101
+ margin-bottom: 20px;
102
+ }
103
+
92
104
  .cpub-products-grid {
93
105
  display: grid;
94
106
  grid-template-columns: repeat(2, 1fr);
@@ -144,7 +156,7 @@ async function handleCreate(): Promise<void> {
144
156
 
145
157
  .cpub-product-card-meta { display: flex; gap: 6px; flex-wrap: wrap; }
146
158
 
147
- @media (max-width: 1024px) {
159
+ @media (max-width: 768px) {
148
160
  .cpub-products-grid { grid-template-columns: 1fr; }
149
161
  }
150
162
 
@@ -217,18 +217,20 @@ async function handleDelete(id: string): Promise<void> {
217
217
  }
218
218
 
219
219
  .cpub-resource-item-ext {
220
- font-size: 9px;
220
+ font-size: 11px;
221
221
  color: var(--text-faint);
222
- margin-left: 4px;
222
+ margin-left: 6px;
223
223
  }
224
224
 
225
225
  .cpub-resource-item-desc {
226
226
  font-size: 11px;
227
227
  color: var(--text-dim);
228
228
  margin-top: 2px;
229
- white-space: nowrap;
229
+ line-height: 1.5;
230
+ display: -webkit-box;
231
+ -webkit-line-clamp: 2;
232
+ -webkit-box-orient: vertical;
230
233
  overflow: hidden;
231
- text-overflow: ellipsis;
232
234
  }
233
235
 
234
236
  .cpub-resource-item-meta {
@@ -246,12 +248,17 @@ async function handleDelete(id: string): Promise<void> {
246
248
  border: none;
247
249
  cursor: pointer;
248
250
  color: var(--text-faint);
249
- padding: 4px;
250
- font-size: 11px;
251
+ padding: 6px 8px;
252
+ font-size: 12px;
253
+ min-width: 32px;
254
+ min-height: 32px;
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: center;
251
258
  }
252
259
 
253
260
  .cpub-resource-delete:hover {
254
- color: var(--red, #ef4444);
261
+ color: var(--red);
255
262
  }
256
263
 
257
264
  @media (max-width: 640px) {
@@ -9,6 +9,7 @@ export interface FeatureFlags {
9
9
  contests: boolean;
10
10
  learning: boolean;
11
11
  explainers: boolean;
12
+ editorial: boolean;
12
13
  federation: boolean;
13
14
  admin: boolean;
14
15
  emailNotifications: boolean;
@@ -16,7 +17,7 @@ export interface FeatureFlags {
16
17
 
17
18
  export function useFeatures() {
18
19
  const config = useRuntimeConfig();
19
- const flags = config.public.features as FeatureFlags;
20
+ const flags = config.public.features as unknown as FeatureFlags;
20
21
 
21
22
  return {
22
23
  features: flags,
@@ -28,6 +29,7 @@ export function useFeatures() {
28
29
  contests: computed(() => flags.contests),
29
30
  learning: computed(() => flags.learning),
30
31
  explainers: computed(() => flags.explainers),
32
+ editorial: computed(() => flags.editorial),
31
33
  federation: computed(() => flags.federation),
32
34
  admin: computed(() => flags.admin),
33
35
  emailNotifications: computed(() => flags.emailNotifications),
package/layouts/admin.vue CHANGED
@@ -29,6 +29,7 @@ const sidebarOpen = ref(false);
29
29
  <NuxtLink to="/admin" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
30
30
  <NuxtLink to="/admin/users" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-users"></i> Users</NuxtLink>
31
31
  <NuxtLink to="/admin/content" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-newspaper"></i> Content</NuxtLink>
32
+ <NuxtLink to="/admin/categories" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-tags"></i> Categories</NuxtLink>
32
33
  <NuxtLink to="/admin/reports" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-flag"></i> Reports</NuxtLink>
33
34
  <NuxtLink to="/admin/audit" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-clipboard-list"></i> Audit Log</NuxtLink>
34
35
  <NuxtLink to="/admin/theme" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-palette"></i> Theme</NuxtLink>
@@ -269,7 +269,7 @@ const userUsername = computed(() => user.value?.username ?? '');
269
269
  </nav>
270
270
  </div>
271
271
  <div class="cpub-footer-bottom">
272
- <span>&copy; {{ new Date().getFullYear() }} {{ siteName }}. Open source under MIT.</span>
272
+ <span>&copy; {{ new Date().getFullYear() }} {{ siteName }}. Open source under AGPL-3.0.</span>
273
273
  </div>
274
274
  </footer>
275
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.11",
32
- "@commonpub/schema": "^0.9.13",
33
- "@commonpub/server": "^2.31.0",
32
+ "@commonpub/schema": "^0.10.0",
33
+ "@commonpub/server": "^2.32.1",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,13 +53,13 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/auth": "0.5.1",
57
- "@commonpub/config": "0.9.1",
56
+ "@commonpub/config": "0.9.2",
58
57
  "@commonpub/docs": "0.6.2",
58
+ "@commonpub/auth": "0.5.1",
59
59
  "@commonpub/editor": "0.7.9",
60
+ "@commonpub/ui": "0.8.5",
60
61
  "@commonpub/learning": "0.5.0",
61
- "@commonpub/protocol": "0.9.9",
62
- "@commonpub/ui": "0.8.5"
62
+ "@commonpub/protocol": "0.9.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,259 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
3
+ useSeoMeta({ title: `Categories — Admin — ${useSiteName()}` });
4
+
5
+ const toast = useToast();
6
+
7
+ interface Category {
8
+ id: string;
9
+ name: string;
10
+ slug: string;
11
+ description: string | null;
12
+ color: string | null;
13
+ icon: string | null;
14
+ sortOrder: number;
15
+ isSystem: boolean;
16
+ }
17
+
18
+ const { data: categories, refresh } = await useFetch<Category[]>('/api/admin/categories');
19
+
20
+ const showForm = ref(false);
21
+ const editingId = ref<string | null>(null);
22
+ const form = ref({
23
+ name: '',
24
+ slug: '',
25
+ description: '',
26
+ color: '',
27
+ icon: '',
28
+ sortOrder: 0,
29
+ });
30
+
31
+ function generateSlug(name: string): string {
32
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
33
+ }
34
+
35
+ function openNew(): void {
36
+ editingId.value = null;
37
+ form.value = { name: '', slug: '', description: '', color: '', icon: '', sortOrder: categories.value?.length ?? 0 };
38
+ showForm.value = true;
39
+ }
40
+
41
+ function openEdit(cat: Category): void {
42
+ editingId.value = cat.id;
43
+ form.value = {
44
+ name: cat.name,
45
+ slug: cat.slug,
46
+ description: cat.description ?? '',
47
+ color: cat.color ?? '',
48
+ icon: cat.icon ?? '',
49
+ sortOrder: cat.sortOrder,
50
+ };
51
+ showForm.value = true;
52
+ }
53
+
54
+ function cancelForm(): void {
55
+ showForm.value = false;
56
+ editingId.value = null;
57
+ }
58
+
59
+ watch(() => form.value.name, (name) => {
60
+ if (!editingId.value) {
61
+ form.value.slug = generateSlug(name);
62
+ }
63
+ });
64
+
65
+ async function saveCategory(): Promise<void> {
66
+ const payload = {
67
+ name: form.value.name,
68
+ slug: form.value.slug,
69
+ description: form.value.description || undefined,
70
+ color: form.value.color || undefined,
71
+ icon: form.value.icon || undefined,
72
+ sortOrder: form.value.sortOrder,
73
+ };
74
+
75
+ try {
76
+ if (editingId.value) {
77
+ await $fetch(`/api/admin/categories/${editingId.value}`, { method: 'PATCH', body: payload });
78
+ toast.success('Category updated');
79
+ } else {
80
+ await $fetch('/api/admin/categories', { method: 'POST', body: payload });
81
+ toast.success('Category created');
82
+ }
83
+ showForm.value = false;
84
+ editingId.value = null;
85
+ await refresh();
86
+ } catch {
87
+ toast.error('Failed to save category');
88
+ }
89
+ }
90
+
91
+ async function deleteCategory(cat: Category): Promise<void> {
92
+ if (cat.isSystem) {
93
+ toast.error('Cannot delete system categories');
94
+ return;
95
+ }
96
+ if (!confirm(`Delete "${cat.name}"? Content using this category will become uncategorized.`)) return;
97
+ try {
98
+ await $fetch(`/api/admin/categories/${cat.id}`, { method: 'DELETE' });
99
+ toast.success('Category deleted');
100
+ await refresh();
101
+ } catch {
102
+ toast.error('Failed to delete category');
103
+ }
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <div class="cpub-admin-categories">
109
+ <div class="cpub-admin-header">
110
+ <h1 class="cpub-admin-title">Content Categories</h1>
111
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="openNew">
112
+ <i class="fa-solid fa-plus"></i> New Category
113
+ </button>
114
+ </div>
115
+
116
+ <!-- Category Form -->
117
+ <div v-if="showForm" class="cpub-cat-form">
118
+ <h2 class="cpub-cat-form-title">{{ editingId ? 'Edit Category' : 'New Category' }}</h2>
119
+ <div class="cpub-cat-form-grid">
120
+ <div class="cpub-cat-field">
121
+ <label class="cpub-cat-label">Name</label>
122
+ <input v-model="form.name" class="cpub-cat-input" placeholder="e.g. Deep Dive" />
123
+ </div>
124
+ <div class="cpub-cat-field">
125
+ <label class="cpub-cat-label">Slug</label>
126
+ <input v-model="form.slug" class="cpub-cat-input" placeholder="e.g. deep-dive" />
127
+ </div>
128
+ <div class="cpub-cat-field">
129
+ <label class="cpub-cat-label">Description</label>
130
+ <input v-model="form.description" class="cpub-cat-input" placeholder="Optional description" />
131
+ </div>
132
+ <div class="cpub-cat-field">
133
+ <label class="cpub-cat-label">Icon (Font Awesome class)</label>
134
+ <input v-model="form.icon" class="cpub-cat-input" placeholder="e.g. fa-solid fa-newspaper" />
135
+ </div>
136
+ <div class="cpub-cat-field">
137
+ <label class="cpub-cat-label">Color</label>
138
+ <input v-model="form.color" class="cpub-cat-input" placeholder="e.g. var(--teal) or #5b9cf6" />
139
+ </div>
140
+ <div class="cpub-cat-field">
141
+ <label class="cpub-cat-label">Sort Order</label>
142
+ <input v-model.number="form.sortOrder" type="number" class="cpub-cat-input" min="0" />
143
+ </div>
144
+ </div>
145
+ <div class="cpub-cat-form-actions">
146
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="saveCategory">
147
+ {{ editingId ? 'Update' : 'Create' }}
148
+ </button>
149
+ <button class="cpub-btn cpub-btn-sm" @click="cancelForm">Cancel</button>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Categories Table -->
154
+ <div class="cpub-admin-table-wrap" v-if="categories?.length">
155
+ <table class="cpub-admin-table">
156
+ <thead>
157
+ <tr>
158
+ <th>Order</th>
159
+ <th>Name</th>
160
+ <th>Slug</th>
161
+ <th>Icon</th>
162
+ <th>Type</th>
163
+ <th>Actions</th>
164
+ </tr>
165
+ </thead>
166
+ <tbody>
167
+ <tr v-for="cat in categories" :key="cat.id">
168
+ <td class="cpub-admin-num">{{ cat.sortOrder }}</td>
169
+ <td>
170
+ <span class="cpub-cat-name">
171
+ <i v-if="cat.icon" :class="cat.icon" :style="{ color: cat.color || 'var(--text-dim)' }"></i>
172
+ {{ cat.name }}
173
+ </span>
174
+ </td>
175
+ <td class="cpub-admin-slug">{{ cat.slug }}</td>
176
+ <td class="cpub-admin-icon-cell"><code v-if="cat.icon">{{ cat.icon }}</code></td>
177
+ <td>
178
+ <span :class="['cpub-cat-type', cat.isSystem ? 'cpub-cat-system' : 'cpub-cat-custom']">
179
+ {{ cat.isSystem ? 'System' : 'Custom' }}
180
+ </span>
181
+ </td>
182
+ <td class="cpub-admin-actions">
183
+ <button class="cpub-admin-action" title="Edit" @click="openEdit(cat)">
184
+ <i class="fa-solid fa-pencil"></i>
185
+ </button>
186
+ <button
187
+ v-if="!cat.isSystem"
188
+ class="cpub-admin-action cpub-admin-action--danger"
189
+ title="Delete"
190
+ @click="deleteCategory(cat)"
191
+ >
192
+ <i class="fa-solid fa-trash"></i>
193
+ </button>
194
+ </td>
195
+ </tr>
196
+ </tbody>
197
+ </table>
198
+ </div>
199
+ <p class="cpub-empty" v-else>No categories found. Create one to get started.</p>
200
+ </div>
201
+ </template>
202
+
203
+ <style scoped>
204
+ .cpub-admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-6); }
205
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
206
+
207
+ .cpub-cat-form {
208
+ background: var(--surface);
209
+ border: var(--border-width-default) solid var(--border);
210
+ padding: var(--space-5);
211
+ margin-bottom: var(--space-6);
212
+ }
213
+
214
+ .cpub-cat-form-title { font-size: var(--text-base); font-weight: 600; margin-bottom: var(--space-4); }
215
+
216
+ .cpub-cat-form-grid {
217
+ display: grid;
218
+ grid-template-columns: 1fr 1fr;
219
+ gap: var(--space-3);
220
+ }
221
+
222
+ .cpub-cat-field { display: flex; flex-direction: column; gap: 4px; }
223
+ .cpub-cat-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
224
+ .cpub-cat-input {
225
+ font-size: 13px;
226
+ padding: 6px 10px;
227
+ border: var(--border-width-default) solid var(--border);
228
+ background: var(--bg);
229
+ color: var(--text);
230
+ outline: none;
231
+ }
232
+ .cpub-cat-input:focus { border-color: var(--accent); }
233
+
234
+ .cpub-cat-form-actions { display: flex; gap: var(--space-2); margin-top: var(--space-4); }
235
+
236
+ .cpub-admin-table-wrap { overflow-x: auto; }
237
+ .cpub-admin-table { width: 100%; border-collapse: collapse; }
238
+ .cpub-admin-table th { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); text-align: left; padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
239
+ .cpub-admin-table td { padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border2); font-size: 13px; }
240
+ .cpub-admin-num { font-family: var(--font-mono); font-size: 11px; color: var(--text-faint); }
241
+ .cpub-admin-slug { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
242
+ .cpub-admin-icon-cell code { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
243
+ .cpub-admin-actions { display: flex; gap: 6px; }
244
+ .cpub-admin-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
245
+ .cpub-admin-action:hover { color: var(--accent); }
246
+ .cpub-admin-action--danger:hover { color: var(--red); }
247
+
248
+ .cpub-cat-name { display: flex; align-items: center; gap: 6px; font-weight: 500; }
249
+ .cpub-cat-type { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 2px 8px; }
250
+ .cpub-cat-system { color: var(--teal); background: var(--teal-bg, var(--surface2)); border: var(--border-width-default) solid var(--teal-border, var(--border2)); }
251
+ .cpub-cat-custom { color: var(--text-dim); background: var(--surface2); border: var(--border-width-default) solid var(--border2); }
252
+
253
+ .cpub-empty { color: var(--text-faint); text-align: center; padding: var(--space-10) 0; }
254
+
255
+ @media (max-width: 768px) {
256
+ .cpub-cat-form-grid { grid-template-columns: 1fr; }
257
+ .cpub-admin-header { flex-direction: column; gap: var(--space-3); align-items: flex-start; }
258
+ }
259
+ </style>
@@ -3,9 +3,72 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
  useSeoMeta({ title: `Content Management — Admin — ${useSiteName()}` });
4
4
 
5
5
  const toast = useToast();
6
+
7
+ interface Category {
8
+ id: string;
9
+ name: string;
10
+ slug: string;
11
+ color: string | null;
12
+ icon: string | null;
13
+ }
14
+
6
15
  const { data, refresh } = await useFetch('/api/content', {
7
16
  query: { limit: 50, sort: 'recent' },
8
17
  });
18
+ const { data: categories } = await useFetch<Category[]>('/api/categories');
19
+
20
+ const selectedIds = ref<Set<string>>(new Set());
21
+ const selectAll = ref(false);
22
+
23
+ watch(selectAll, (val) => {
24
+ if (val && data.value?.items) {
25
+ selectedIds.value = new Set(data.value.items.filter(i => i.source !== 'federated').map(i => i.id));
26
+ } else {
27
+ selectedIds.value = new Set();
28
+ }
29
+ });
30
+
31
+ function toggleSelect(id: string): void {
32
+ const s = new Set(selectedIds.value);
33
+ if (s.has(id)) s.delete(id); else s.add(id);
34
+ selectedIds.value = s;
35
+ }
36
+
37
+ async function bulkAction(action: 'feature' | 'unfeature' | 'editorial' | 'uneditorial'): Promise<void> {
38
+ if (selectedIds.value.size === 0) return;
39
+ const ids = Array.from(selectedIds.value);
40
+ const body: Record<string, unknown> = { ids };
41
+ if (action === 'feature') body.isFeatured = true;
42
+ if (action === 'unfeature') body.isFeatured = false;
43
+ if (action === 'editorial') body.isEditorial = true;
44
+ if (action === 'uneditorial') body.isEditorial = false;
45
+
46
+ try {
47
+ await $fetch('/api/admin/content/bulk-editorial', { method: 'POST', body });
48
+ toast.success(`Updated ${ids.length} items`);
49
+ selectedIds.value = new Set();
50
+ selectAll.value = false;
51
+ await refresh();
52
+ } catch {
53
+ toast.error('Bulk action failed');
54
+ }
55
+ }
56
+
57
+ async function bulkSetCategory(categoryId: string | null): Promise<void> {
58
+ if (selectedIds.value.size === 0) return;
59
+ try {
60
+ await $fetch('/api/admin/content/bulk-editorial', {
61
+ method: 'POST',
62
+ body: { ids: Array.from(selectedIds.value), categoryId },
63
+ });
64
+ toast.success('Categories updated');
65
+ selectedIds.value = new Set();
66
+ selectAll.value = false;
67
+ await refresh();
68
+ } catch {
69
+ toast.error('Failed to update categories');
70
+ }
71
+ }
9
72
 
10
73
  async function removeContent(id: string, title: string): Promise<void> {
11
74
  if (!confirm(`Remove "${title}"? This cannot be undone.`)) return;
@@ -30,18 +93,61 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
30
93
  toast.error('Failed to update featured status');
31
94
  }
32
95
  }
96
+
97
+ async function toggleEditorial(id: string, current: boolean): Promise<void> {
98
+ try {
99
+ await $fetch(`/api/admin/content/${id}`, {
100
+ method: 'PATCH',
101
+ body: { isEditorial: !current },
102
+ });
103
+ toast.success(current ? 'Removed from Staff Picks' : 'Marked as Staff Pick');
104
+ await refresh();
105
+ } catch {
106
+ toast.error('Failed to update editorial status');
107
+ }
108
+ }
109
+
110
+ async function setCategory(id: string, categoryId: string | null): Promise<void> {
111
+ try {
112
+ await $fetch(`/api/admin/content/${id}`, {
113
+ method: 'PATCH',
114
+ body: { categoryId },
115
+ });
116
+ toast.success('Category updated');
117
+ await refresh();
118
+ } catch {
119
+ toast.error('Failed to update category');
120
+ }
121
+ }
33
122
  </script>
34
123
 
35
124
  <template>
36
125
  <div class="cpub-admin-content">
37
126
  <h1 class="cpub-admin-title">Content Management</h1>
38
127
 
128
+ <!-- Bulk Actions Bar -->
129
+ <div v-if="selectedIds.size > 0" class="cpub-bulk-bar">
130
+ <span class="cpub-bulk-count">{{ selectedIds.size }} selected</span>
131
+ <button class="cpub-btn cpub-btn-sm" @click="bulkAction('feature')"><i class="fa-solid fa-star"></i> Feature</button>
132
+ <button class="cpub-btn cpub-btn-sm" @click="bulkAction('unfeature')"><i class="fa-regular fa-star"></i> Unfeature</button>
133
+ <button class="cpub-btn cpub-btn-sm" @click="bulkAction('editorial')"><i class="fa-solid fa-pen-fancy"></i> Staff Pick</button>
134
+ <button class="cpub-btn cpub-btn-sm" @click="bulkAction('uneditorial')"><i class="fa-regular fa-pen-to-square"></i> Unpick</button>
135
+ <select class="cpub-bulk-cat-select" @change="(e) => bulkSetCategory((e.target as HTMLSelectElement).value || null)" aria-label="Set category">
136
+ <option value="">Set Category...</option>
137
+ <option :value="''" v-if="false">—</option>
138
+ <option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
139
+ <option value="">Remove Category</option>
140
+ </select>
141
+ </div>
142
+
39
143
  <div class="cpub-admin-table-wrap" v-if="data?.items?.length">
40
144
  <table class="cpub-admin-table">
41
145
  <thead>
42
146
  <tr>
147
+ <th><input type="checkbox" v-model="selectAll" aria-label="Select all" /></th>
43
148
  <th>Title</th>
44
149
  <th>Type</th>
150
+ <th>Category</th>
45
151
  <th>Author</th>
46
152
  <th>Status</th>
47
153
  <th>Views</th>
@@ -50,11 +156,36 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
50
156
  </tr>
51
157
  </thead>
52
158
  <tbody>
53
- <tr v-for="item in data.items" :key="item.id">
159
+ <tr v-for="item in data.items" :key="item.id" :class="{ 'cpub-row-selected': selectedIds.has(item.id) }">
160
+ <td>
161
+ <input
162
+ v-if="item.source !== 'federated'"
163
+ type="checkbox"
164
+ :checked="selectedIds.has(item.id)"
165
+ @change="toggleSelect(item.id)"
166
+ :aria-label="`Select ${item.title}`"
167
+ />
168
+ </td>
54
169
  <td>
55
170
  <NuxtLink :to="`/u/${item.author?.username}/${item.type}/${item.slug}`" class="cpub-admin-link">{{ item.title }}</NuxtLink>
171
+ <div class="cpub-admin-badges" v-if="item.isEditorial || item.isFeatured">
172
+ <span v-if="item.isEditorial" class="cpub-mini-badge cpub-mini-badge--editorial"><i class="fa-solid fa-pen-fancy"></i> Staff Pick</span>
173
+ <span v-if="item.isFeatured" class="cpub-mini-badge cpub-mini-badge--featured"><i class="fa-solid fa-star"></i></span>
174
+ </div>
56
175
  </td>
57
176
  <td><ContentTypeBadge :type="item.type" /></td>
177
+ <td>
178
+ <select
179
+ v-if="item.source !== 'federated'"
180
+ class="cpub-cat-select"
181
+ :value="item.categoryId || ''"
182
+ @change="setCategory(item.id, ($event.target as HTMLSelectElement).value || null)"
183
+ aria-label="Category"
184
+ >
185
+ <option value="">None</option>
186
+ <option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
187
+ </select>
188
+ </td>
58
189
  <td class="cpub-admin-author">{{ item.author?.displayName || item.author?.username || 'Unknown' }}</td>
59
190
  <td>
60
191
  <span :class="['cpub-status-badge', `cpub-status-${item.status}`]">{{ item.status }}</span>
@@ -65,7 +196,16 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
65
196
  <button
66
197
  v-if="item.source !== 'federated'"
67
198
  class="cpub-admin-action"
68
- :class="{ 'cpub-admin-action--active': item.isFeatured }"
199
+ :class="{ 'cpub-admin-action--active': item.isEditorial }"
200
+ :title="item.isEditorial ? 'Remove Staff Pick' : 'Mark as Staff Pick'"
201
+ @click="toggleEditorial(item.id, !!item.isEditorial)"
202
+ >
203
+ <i class="fa-solid fa-pen-fancy"></i>
204
+ </button>
205
+ <button
206
+ v-if="item.source !== 'federated'"
207
+ class="cpub-admin-action"
208
+ :class="{ 'cpub-admin-action--active-star': item.isFeatured }"
69
209
  :title="item.isFeatured ? 'Remove from featured' : 'Feature on homepage'"
70
210
  @click="toggleFeatured(item.id, !!item.isFeatured)"
71
211
  >
@@ -88,6 +228,28 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
88
228
 
89
229
  <style scoped>
90
230
  .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-6); }
231
+
232
+ .cpub-bulk-bar {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: var(--space-2);
236
+ padding: var(--space-3) var(--space-4);
237
+ background: var(--accent-bg);
238
+ border: var(--border-width-default) solid var(--accent-border);
239
+ margin-bottom: var(--space-4);
240
+ flex-wrap: wrap;
241
+ }
242
+ .cpub-bulk-count { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--accent); margin-right: var(--space-2); }
243
+ .cpub-bulk-cat-select {
244
+ font-family: var(--font-mono);
245
+ font-size: 10px;
246
+ padding: 4px 8px;
247
+ border: var(--border-width-default) solid var(--border);
248
+ background: var(--surface);
249
+ color: var(--text-dim);
250
+ cursor: pointer;
251
+ }
252
+
91
253
  .cpub-admin-table-wrap { overflow-x: auto; }
92
254
  .cpub-admin-table { width: 100%; border-collapse: collapse; }
93
255
  .cpub-admin-table th { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); text-align: left; padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
@@ -103,9 +265,25 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
103
265
  .cpub-admin-actions { display: flex; gap: 6px; }
104
266
  .cpub-admin-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
105
267
  .cpub-admin-action:hover { color: var(--accent); }
106
- .cpub-admin-action--active { color: var(--yellow, #e6b800); }
268
+ .cpub-admin-action--active { color: var(--teal); }
269
+ .cpub-admin-action--active-star { color: var(--yellow, #e6b800); }
107
270
  .cpub-admin-action--danger:hover { color: var(--red); }
108
271
  .cpub-admin-federated-tag { font-family: var(--font-mono); font-size: 9px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.04em; display: flex; align-items: center; gap: 3px; }
272
+ .cpub-row-selected { background: var(--accent-bg); }
273
+ .cpub-admin-badges { display: flex; gap: 4px; margin-top: 2px; }
274
+ .cpub-mini-badge { font-family: var(--font-mono); font-size: 9px; display: inline-flex; align-items: center; gap: 3px; }
275
+ .cpub-mini-badge--editorial { color: var(--teal); }
276
+ .cpub-mini-badge--featured { color: var(--yellow, #e6b800); }
277
+ .cpub-cat-select {
278
+ font-family: var(--font-mono);
279
+ font-size: 10px;
280
+ padding: 3px 6px;
281
+ border: var(--border-width-default) solid var(--border2);
282
+ background: var(--surface);
283
+ color: var(--text-dim);
284
+ cursor: pointer;
285
+ max-width: 120px;
286
+ }
109
287
  .cpub-empty { color: var(--text-faint); text-align: center; padding: var(--space-10) 0; }
110
288
 
111
289
  @media (max-width: 768px) {
package/pages/explore.vue CHANGED
@@ -478,6 +478,8 @@ const sortOptions = [
478
478
  text-decoration: none;
479
479
  color: inherit;
480
480
  transition: box-shadow 0.15s;
481
+ min-width: 0;
482
+ overflow: hidden;
481
483
  }
482
484
 
483
485
  .cpub-explore-hub-card:hover { box-shadow: var(--shadow-md); }
@@ -499,7 +501,7 @@ const sortOptions = [
499
501
 
500
502
  .cpub-explore-hub-body { flex: 1; min-width: 0; }
501
503
  .cpub-explore-hub-name { font-size: 13px; font-weight: 600; margin-bottom: 3px; }
502
- .cpub-explore-hub-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; margin-bottom: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
504
+ .cpub-explore-hub-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
503
505
  .cpub-explore-hub-meta { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); display: flex; gap: 8px; align-items: center; }
504
506
 
505
507
  /* Path cards */
@@ -539,12 +541,9 @@ const sortOptions = [
539
541
  padding: 24px 0;
540
542
  }
541
543
 
542
- @media (max-width: 1024px) {
543
- .cpub-explore-hub-grid { grid-template-columns: 1fr; }
544
- }
545
-
546
544
  @media (max-width: 768px) {
547
545
  .cpub-explore-grid { grid-template-columns: 1fr; }
546
+ .cpub-explore-hub-grid { grid-template-columns: 1fr; }
548
547
  .cpub-explore-filters { flex-wrap: wrap; }
549
548
  }
550
549
  </style>
@@ -126,31 +126,42 @@ const hubRules = computed<string[]>(() => {
126
126
  });
127
127
 
128
128
  const tabDefs = computed<HubTabDef[]>(() => {
129
+ const isMember = !!hub.value?.currentUserRole;
130
+ const showResources = (resources.value?.total ?? 0) > 0 || isMember;
131
+
129
132
  if (isProductHub.value) {
130
- return [
133
+ const tabs: HubTabDef[] = [
131
134
  { value: 'overview', label: 'Overview', icon: 'fa-solid fa-info-circle' },
132
135
  { value: 'projects', label: 'Projects Using This', icon: 'fa-solid fa-folder-open', count: gallery.value?.total },
133
136
  { value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
134
- { value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
135
137
  ];
138
+ if (showResources) tabs.push({ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total });
139
+ return tabs;
136
140
  }
137
141
  if (isCompanyHub.value) {
138
- return [
142
+ const tabs: HubTabDef[] = [
139
143
  { value: 'overview', label: 'Overview', icon: 'fa-solid fa-building' },
140
144
  { value: 'products', label: 'Products', icon: 'fa-solid fa-microchip', count: products.value?.total },
141
145
  { value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: gallery.value?.total },
142
146
  { value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
143
- { value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
144
147
  ];
148
+ if (showResources) tabs.push({ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total });
149
+ return tabs;
145
150
  }
146
- return [
151
+ // Community hubs: only show Products/Resources tabs if they have content or user is a member
152
+ const tabs: HubTabDef[] = [
147
153
  { value: 'feed', label: 'Feed', icon: 'fa-solid fa-rss', count: hub.value?.postCount },
148
154
  { value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: gallery.value?.total },
149
- { value: 'products', label: 'Products', icon: 'fa-solid fa-microchip', count: products.value?.total },
150
- { value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
151
- { value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
152
- { value: 'members', label: 'Members', icon: 'fa-solid fa-users', count: hub.value?.memberCount },
153
155
  ];
156
+ if ((products.value?.total ?? 0) > 0 || isMember) {
157
+ tabs.push({ value: 'products', label: 'Products', icon: 'fa-solid fa-microchip', count: products.value?.total });
158
+ }
159
+ tabs.push({ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' });
160
+ if (showResources) {
161
+ tabs.push({ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total });
162
+ }
163
+ tabs.push({ value: 'members', label: 'Members', icon: 'fa-solid fa-users', count: hub.value?.memberCount });
164
+ return tabs;
154
165
  });
155
166
 
156
167
  const toast = useToast();
package/pages/index.vue CHANGED
@@ -7,7 +7,7 @@ useSeoMeta({
7
7
  });
8
8
 
9
9
  const { user: authUser } = useAuth();
10
- const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled } = useFeatures();
10
+ const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled } = useFeatures();
11
11
  const { enabledTypeMeta } = useContentTypes();
12
12
 
13
13
  const activeTab = ref(authUser.value ? 'foryou' : 'latest');
@@ -33,7 +33,14 @@ const { data: feed, pending: feedPending } = await useFetch<PaginatedResponse<Se
33
33
  watch: [contentQuery],
34
34
  });
35
35
 
36
- // Only show featured card if an admin has explicitly featured something
36
+ // Editorial picks staff-curated content for the homepage (only when editorial feature is enabled)
37
+ const { data: editorialPicks } = editorialEnabled.value
38
+ ? await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
39
+ query: { status: 'published', editorial: true, sort: 'editorial', limit: 3 },
40
+ })
41
+ : { data: ref(null) };
42
+
43
+ // Legacy featured fallback — if no editorial picks, show single featured card
37
44
  const { data: featured } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
38
45
  query: { status: 'published', featured: true, limit: 1 },
39
46
  });
@@ -214,8 +221,18 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
214
221
  <!-- Feed column -->
215
222
  <main class="cpub-feed-col">
216
223
 
217
- <!-- Featured card -->
218
- <article v-if="featured?.items?.length && activeTab === 'foryou'" class="cpub-featured-card">
224
+ <!-- Editorial Picks Section -->
225
+ <section v-if="editorialEnabled && editorialPicks?.items?.length && activeTab === 'foryou'" class="cpub-editorial-section">
226
+ <div class="cpub-editorial-header">
227
+ <h2 class="cpub-editorial-heading"><i class="fa-solid fa-pen-fancy"></i> Staff Picks</h2>
228
+ </div>
229
+ <div class="cpub-editorial-grid" :class="{ 'cpub-editorial-single': editorialPicks.items.length === 1 }">
230
+ <ContentCard v-for="item in editorialPicks.items" :key="item.id" :item="item" />
231
+ </div>
232
+ </section>
233
+
234
+ <!-- Legacy featured card (shown when no editorial picks exist) -->
235
+ <article v-else-if="featured?.items?.length && activeTab === 'foryou'" class="cpub-featured-card">
219
236
  <div class="cpub-featured-thumb" :style="featured.items[0].coverImageUrl ? { backgroundImage: `url(${featured.items[0].coverImageUrl.includes('://') && !featured.items[0].coverImageUrl.includes(useRuntimeConfig().public?.domain as string || 'localhost') ? `/api/image-proxy?url=${encodeURIComponent(featured.items[0].coverImageUrl)}&w=900` : featured.items[0].coverImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}">
220
237
  <i v-if="!featured.items[0].coverImageUrl" class="cpub-thumb-icon fa-solid fa-microchip" />
221
238
  <div class="cpub-thumb-overlay">
@@ -571,6 +588,49 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
571
588
 
572
589
  .cpub-feed-col { min-width: 0; }
573
590
 
591
+ /* ─── EDITORIAL PICKS ─── */
592
+ .cpub-editorial-section {
593
+ margin-bottom: 24px;
594
+ }
595
+
596
+ .cpub-editorial-header {
597
+ display: flex;
598
+ align-items: center;
599
+ justify-content: space-between;
600
+ margin-bottom: 12px;
601
+ }
602
+
603
+ .cpub-editorial-heading {
604
+ font-family: var(--font-mono);
605
+ font-size: 11px;
606
+ font-weight: 700;
607
+ letter-spacing: 0.08em;
608
+ text-transform: uppercase;
609
+ color: var(--teal);
610
+ display: flex;
611
+ align-items: center;
612
+ gap: 6px;
613
+ }
614
+
615
+ .cpub-editorial-heading i {
616
+ font-size: 10px;
617
+ }
618
+
619
+ .cpub-editorial-grid {
620
+ display: grid;
621
+ grid-template-columns: repeat(3, 1fr);
622
+ gap: 16px;
623
+ }
624
+
625
+ .cpub-editorial-single {
626
+ grid-template-columns: 1fr;
627
+ max-width: 400px;
628
+ }
629
+
630
+ @media (max-width: 768px) {
631
+ .cpub-editorial-grid { grid-template-columns: 1fr; }
632
+ }
633
+
574
634
  /* ─── FEATURED CARD ─── */
575
635
  .cpub-featured-card {
576
636
  background: var(--surface);
@@ -0,0 +1,20 @@
1
+ import { deleteContentCategory } from '@commonpub/server';
2
+
3
+ /**
4
+ * DELETE /api/admin/categories/[id]
5
+ * Delete a content category (admin only). System categories cannot be deleted.
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireAdmin(event);
9
+ const db = useDB();
10
+ const { id } = parseParams(event, { id: 'uuid' });
11
+
12
+ const result = await deleteContentCategory(db, id);
13
+ if (!result.deleted) {
14
+ if (result.error === 'system_category') {
15
+ throw createError({ statusCode: 403, statusMessage: 'System categories cannot be deleted' });
16
+ }
17
+ throw createError({ statusCode: 404, statusMessage: 'Category not found' });
18
+ }
19
+ return { success: true };
20
+ });
@@ -0,0 +1,19 @@
1
+ import { updateContentCategory } from '@commonpub/server';
2
+ import { updateContentCategorySchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * PATCH /api/admin/categories/[id]
6
+ * Update a content category (admin only).
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ requireAdmin(event);
10
+ const db = useDB();
11
+ const { id } = parseParams(event, { id: 'uuid' });
12
+ const body = await parseBody(event, updateContentCategorySchema);
13
+
14
+ const result = await updateContentCategory(db, id, body);
15
+ if (!result) {
16
+ throw createError({ statusCode: 404, statusMessage: 'Category not found' });
17
+ }
18
+ return result;
19
+ });
@@ -0,0 +1,11 @@
1
+ import { listContentCategories } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/admin/categories
5
+ * List all content categories (admin).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireAdmin(event);
9
+ const db = useDB();
10
+ return listContentCategories(db);
11
+ });
@@ -0,0 +1,13 @@
1
+ import { createContentCategory } from '@commonpub/server';
2
+ import { createContentCategorySchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * POST /api/admin/categories
6
+ * Create a new content category (admin only).
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ requireAdmin(event);
10
+ const db = useDB();
11
+ const body = await parseBody(event, createContentCategorySchema);
12
+ return createContentCategory(db, body);
13
+ });
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
 
5
5
  /**
6
6
  * PATCH /api/admin/content/[id]
7
- * Update admin-managed content fields (featured status, visibility).
7
+ * Update admin-managed content fields (featured, editorial, category).
8
8
  */
9
9
  export default defineEventHandler(async (event) => {
10
10
  requireAdmin(event);
@@ -12,12 +12,18 @@ export default defineEventHandler(async (event) => {
12
12
  const { id: contentId } = parseParams(event, { id: 'uuid' });
13
13
  const body = await parseBody(event, z.object({
14
14
  isFeatured: z.boolean().optional(),
15
+ isEditorial: z.boolean().optional(),
16
+ editorialNote: z.string().max(255).optional().nullable(),
17
+ categoryId: z.string().uuid().optional().nullable(),
15
18
  }));
16
19
 
17
20
  const db = useDB();
18
21
 
19
22
  const updates: Record<string, unknown> = {};
20
23
  if (body.isFeatured !== undefined) updates.isFeatured = body.isFeatured;
24
+ if (body.isEditorial !== undefined) updates.isEditorial = body.isEditorial;
25
+ if (body.editorialNote !== undefined) updates.editorialNote = body.editorialNote;
26
+ if (body.categoryId !== undefined) updates.categoryId = body.categoryId;
21
27
 
22
28
  if (Object.keys(updates).length === 0) {
23
29
  throw createError({ statusCode: 400, statusMessage: 'No fields to update' });
@@ -27,7 +33,13 @@ export default defineEventHandler(async (event) => {
27
33
  .update(contentItems)
28
34
  .set(updates)
29
35
  .where(eq(contentItems.id, contentId))
30
- .returning({ id: contentItems.id, isFeatured: contentItems.isFeatured });
36
+ .returning({
37
+ id: contentItems.id,
38
+ isFeatured: contentItems.isFeatured,
39
+ isEditorial: contentItems.isEditorial,
40
+ editorialNote: contentItems.editorialNote,
41
+ categoryId: contentItems.categoryId,
42
+ });
31
43
 
32
44
  if (result.length === 0) {
33
45
  throw createError({ statusCode: 404, statusMessage: 'Content not found' });
@@ -0,0 +1,37 @@
1
+ import { contentItems } from '@commonpub/schema';
2
+ import { inArray } from 'drizzle-orm';
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * POST /api/admin/content/bulk-editorial
7
+ * Bulk update editorial status on multiple content items (admin only).
8
+ */
9
+ export default defineEventHandler(async (event) => {
10
+ requireAdmin(event);
11
+
12
+ const body = await parseBody(event, z.object({
13
+ ids: z.array(z.string().uuid()).min(1).max(100),
14
+ isEditorial: z.boolean().optional(),
15
+ isFeatured: z.boolean().optional(),
16
+ categoryId: z.string().uuid().optional().nullable(),
17
+ }));
18
+
19
+ const db = useDB();
20
+
21
+ const updates: Record<string, unknown> = {};
22
+ if (body.isEditorial !== undefined) updates.isEditorial = body.isEditorial;
23
+ if (body.isFeatured !== undefined) updates.isFeatured = body.isFeatured;
24
+ if (body.categoryId !== undefined) updates.categoryId = body.categoryId;
25
+
26
+ if (Object.keys(updates).length === 0) {
27
+ throw createError({ statusCode: 400, statusMessage: 'No fields to update' });
28
+ }
29
+
30
+ const result = await db
31
+ .update(contentItems)
32
+ .set(updates)
33
+ .where(inArray(contentItems.id, body.ids))
34
+ .returning({ id: contentItems.id });
35
+
36
+ return { updated: result.length };
37
+ });
@@ -0,0 +1,10 @@
1
+ import { listContentCategories } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/categories
5
+ * List all content categories (public).
6
+ */
7
+ export default defineEventHandler(async () => {
8
+ const db = useDB();
9
+ return listContentCategories(db);
10
+ });