@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.
- package/components/CategoryBadge.vue +38 -0
- package/components/ContentCard.vue +18 -2
- package/components/EditorialBadge.vue +28 -0
- package/components/hub/HubLayout.vue +17 -0
- package/components/hub/HubProducts.vue +16 -4
- package/components/hub/HubResources.vue +14 -7
- package/composables/useFeatures.ts +3 -1
- package/layouts/admin.vue +1 -0
- package/layouts/default.vue +1 -1
- package/package.json +7 -7
- package/pages/admin/categories.vue +259 -0
- package/pages/admin/content.vue +181 -3
- package/pages/explore.vue +4 -5
- package/pages/hubs/[slug]/index.vue +20 -9
- package/pages/index.vue +64 -4
- package/server/api/admin/categories/[id].delete.ts +20 -0
- package/server/api/admin/categories/[id].patch.ts +19 -0
- package/server/api/admin/categories/index.get.ts +11 -0
- package/server/api/admin/categories/index.post.ts +13 -0
- package/server/api/admin/content/[id].patch.ts +14 -2
- package/server/api/admin/content/bulk-editorial.post.ts +37 -0
- package/server/api/categories/index.get.ts +10 -0
|
@@ -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> & {
|
|
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
|
-
<
|
|
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:
|
|
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:
|
|
220
|
+
font-size: 11px;
|
|
221
221
|
color: var(--text-faint);
|
|
222
|
-
margin-left:
|
|
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
|
-
|
|
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:
|
|
250
|
-
font-size:
|
|
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
|
|
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>
|
package/layouts/default.vue
CHANGED
|
@@ -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>© {{ new Date().getFullYear() }} {{ siteName }}. Open source under
|
|
272
|
+
<span>© {{ 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.
|
|
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.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
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/
|
|
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>
|
package/pages/admin/content.vue
CHANGED
|
@@ -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.
|
|
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(--
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
<!--
|
|
218
|
-
<
|
|
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
|
|
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({
|
|
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
|
+
});
|