@commonpub/layer 0.8.9 → 0.9.0
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/hub/HubProducts.vue +62 -1
- package/components/hub/HubResources.vue +264 -0
- package/package.json +3 -3
- package/pages/hubs/[slug]/index.vue +15 -3
- package/server/api/hubs/[slug]/resources/[id].delete.ts +16 -0
- package/server/api/hubs/[slug]/resources/[id].put.ts +20 -0
- package/server/api/hubs/[slug]/resources/index.get.ts +13 -0
- package/server/api/hubs/[slug]/resources/index.post.ts +16 -0
- package/server/api/hubs/[slug]/resources/reorder.post.ts +22 -0
- package/server/routes/hubs/[slug]/products.ts +47 -0
- package/server/routes/hubs/[slug]/resources.ts +46 -0
|
@@ -1,10 +1,70 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
2
|
+
import { createProductSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
3
5
|
products: { items: Array<{ id: string; name: string; description: string | null; imageUrl: string | null; category: string | null; status: string }>; total: number } | null;
|
|
6
|
+
currentUserRole?: string | null;
|
|
7
|
+
hubSlug?: string;
|
|
4
8
|
}>();
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{ 'product-created': [] }>();
|
|
11
|
+
|
|
12
|
+
const canManage = computed(() => ['owner', 'admin', 'moderator'].includes(props.currentUserRole ?? ''));
|
|
13
|
+
const showForm = ref(false);
|
|
14
|
+
const formName = ref('');
|
|
15
|
+
const formDescription = ref('');
|
|
16
|
+
const formCategory = ref('other');
|
|
17
|
+
const formPurchaseUrl = ref('');
|
|
18
|
+
const creating = ref(false);
|
|
19
|
+
|
|
20
|
+
async function handleCreate(): Promise<void> {
|
|
21
|
+
if (!formName.value.trim() || !props.hubSlug) return;
|
|
22
|
+
creating.value = true;
|
|
23
|
+
try {
|
|
24
|
+
await $fetch(`/api/hubs/${props.hubSlug}/products`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
body: { name: formName.value, description: formDescription.value || undefined, category: formCategory.value, purchaseUrl: formPurchaseUrl.value || undefined },
|
|
27
|
+
});
|
|
28
|
+
formName.value = '';
|
|
29
|
+
formDescription.value = '';
|
|
30
|
+
formCategory.value = 'other';
|
|
31
|
+
formPurchaseUrl.value = '';
|
|
32
|
+
showForm.value = false;
|
|
33
|
+
emit('product-created');
|
|
34
|
+
} catch { /* toast error */ }
|
|
35
|
+
finally { creating.value = false; }
|
|
36
|
+
}
|
|
5
37
|
</script>
|
|
6
38
|
|
|
7
39
|
<template>
|
|
40
|
+
<div>
|
|
41
|
+
<div v-if="canManage && hubSlug" class="cpub-products-header">
|
|
42
|
+
<button class="cpub-btn cpub-btn-sm" @click="showForm = !showForm">
|
|
43
|
+
<i :class="showForm ? 'fa-solid fa-times' : 'fa-solid fa-plus'"></i>
|
|
44
|
+
{{ showForm ? 'Cancel' : 'Add Product' }}
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<form v-if="showForm" class="cpub-resource-form" @submit.prevent="handleCreate">
|
|
49
|
+
<input v-model="formName" type="text" placeholder="Product name" class="cpub-input" required />
|
|
50
|
+
<input v-model="formDescription" type="text" placeholder="Short description (optional)" class="cpub-input" />
|
|
51
|
+
<select v-model="formCategory" class="cpub-input">
|
|
52
|
+
<option value="microcontroller">Microcontroller</option>
|
|
53
|
+
<option value="sbc">SBC</option>
|
|
54
|
+
<option value="sensor">Sensor</option>
|
|
55
|
+
<option value="display">Display</option>
|
|
56
|
+
<option value="communication">Communication</option>
|
|
57
|
+
<option value="power">Power</option>
|
|
58
|
+
<option value="software">Software</option>
|
|
59
|
+
<option value="tool">Tool</option>
|
|
60
|
+
<option value="other">Other</option>
|
|
61
|
+
</select>
|
|
62
|
+
<input v-model="formPurchaseUrl" type="url" placeholder="Purchase URL (optional)" class="cpub-input" />
|
|
63
|
+
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="creating || !formName.trim()">
|
|
64
|
+
{{ creating ? 'Adding...' : 'Add Product' }}
|
|
65
|
+
</button>
|
|
66
|
+
</form>
|
|
67
|
+
|
|
8
68
|
<div v-if="products?.items?.length" class="cpub-products-grid">
|
|
9
69
|
<div v-for="product in products.items" :key="product.id" class="cpub-product-card">
|
|
10
70
|
<div class="cpub-product-card-icon">
|
|
@@ -25,6 +85,7 @@ defineProps<{
|
|
|
25
85
|
<div class="cpub-empty-state-icon"><i class="fa-solid fa-microchip"></i></div>
|
|
26
86
|
<p class="cpub-empty-state-title">No products listed yet</p>
|
|
27
87
|
</div>
|
|
88
|
+
</div>
|
|
28
89
|
</template>
|
|
29
90
|
|
|
30
91
|
<style scoped>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
resources: { items: Array<{ id: string; title: string; url: string; description: string | null; category: string; sortOrder: number; addedBy: { id: string; username: string; displayName: string | null; avatarUrl: string | null }; createdAt: string; updatedAt: string }>; total: number } | null;
|
|
4
|
+
currentUserRole?: string | null;
|
|
5
|
+
hubSlug?: string;
|
|
6
|
+
isAuthenticated?: boolean;
|
|
7
|
+
authUserId?: string | null;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{ 'resource-changed': [] }>();
|
|
11
|
+
|
|
12
|
+
const canManage = computed(() => ['owner', 'admin', 'moderator'].includes(props.currentUserRole ?? ''));
|
|
13
|
+
const isMember = computed(() => !!props.currentUserRole);
|
|
14
|
+
|
|
15
|
+
const showForm = ref(false);
|
|
16
|
+
const formTitle = ref('');
|
|
17
|
+
const formUrl = ref('');
|
|
18
|
+
const formDescription = ref('');
|
|
19
|
+
const formCategory = ref('other');
|
|
20
|
+
const creating = ref(false);
|
|
21
|
+
|
|
22
|
+
const categoryLabels: Record<string, string> = {
|
|
23
|
+
documentation: 'Documentation',
|
|
24
|
+
tools: 'Tools',
|
|
25
|
+
tutorials: 'Tutorials',
|
|
26
|
+
community: 'Community',
|
|
27
|
+
hardware: 'Hardware',
|
|
28
|
+
software: 'Software',
|
|
29
|
+
other: 'Other',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ResourceItem = NonNullable<typeof props.resources>['items'][number];
|
|
33
|
+
|
|
34
|
+
const groupedResources = computed(() => {
|
|
35
|
+
const groups: Record<string, ResourceItem[]> = {};
|
|
36
|
+
for (const item of props.resources?.items ?? []) {
|
|
37
|
+
if (!groups[item.category]) groups[item.category] = [];
|
|
38
|
+
groups[item.category]!.push(item);
|
|
39
|
+
}
|
|
40
|
+
const order = ['documentation', 'tools', 'tutorials', 'community', 'hardware', 'software', 'other'];
|
|
41
|
+
const sorted: Array<{ category: string; label: string; items: ResourceItem[] }> = [];
|
|
42
|
+
for (const cat of order) {
|
|
43
|
+
if (groups[cat]?.length) {
|
|
44
|
+
sorted.push({ category: cat, label: categoryLabels[cat] ?? cat, items: groups[cat]! });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return sorted;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function handleCreate(): Promise<void> {
|
|
51
|
+
if (!formTitle.value.trim() || !formUrl.value.trim() || !props.hubSlug) return;
|
|
52
|
+
creating.value = true;
|
|
53
|
+
try {
|
|
54
|
+
await $fetch(`/api/hubs/${props.hubSlug}/resources`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
body: {
|
|
57
|
+
title: formTitle.value,
|
|
58
|
+
url: formUrl.value,
|
|
59
|
+
description: formDescription.value || undefined,
|
|
60
|
+
category: formCategory.value,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
formTitle.value = '';
|
|
64
|
+
formUrl.value = '';
|
|
65
|
+
formDescription.value = '';
|
|
66
|
+
formCategory.value = 'other';
|
|
67
|
+
showForm.value = false;
|
|
68
|
+
emit('resource-changed');
|
|
69
|
+
} catch { /* toast error */ }
|
|
70
|
+
finally { creating.value = false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleDelete(id: string): Promise<void> {
|
|
74
|
+
if (!props.hubSlug) return;
|
|
75
|
+
try {
|
|
76
|
+
await $fetch(`/api/hubs/${props.hubSlug}/resources/${id}`, { method: 'DELETE' });
|
|
77
|
+
emit('resource-changed');
|
|
78
|
+
} catch { /* toast error */ }
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div class="cpub-resources">
|
|
84
|
+
<div v-if="isMember && hubSlug" class="cpub-resources-header">
|
|
85
|
+
<button class="cpub-btn cpub-btn-sm" @click="showForm = !showForm">
|
|
86
|
+
<i :class="showForm ? 'fa-solid fa-times' : 'fa-solid fa-plus'"></i>
|
|
87
|
+
{{ showForm ? 'Cancel' : 'Add Resource' }}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<form v-if="showForm" class="cpub-resource-form" @submit.prevent="handleCreate">
|
|
92
|
+
<input v-model="formTitle" type="text" placeholder="Resource title" class="cpub-input" required maxlength="255" />
|
|
93
|
+
<input v-model="formUrl" type="url" placeholder="https://..." class="cpub-input" required />
|
|
94
|
+
<input v-model="formDescription" type="text" placeholder="Short description (optional)" class="cpub-input" maxlength="2000" />
|
|
95
|
+
<select v-model="formCategory" class="cpub-input" aria-label="Resource category">
|
|
96
|
+
<option v-for="(label, key) in categoryLabels" :key="key" :value="key">{{ label }}</option>
|
|
97
|
+
</select>
|
|
98
|
+
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="creating || !formTitle.trim() || !formUrl.trim()">
|
|
99
|
+
{{ creating ? 'Adding...' : 'Add Resource' }}
|
|
100
|
+
</button>
|
|
101
|
+
</form>
|
|
102
|
+
|
|
103
|
+
<template v-if="groupedResources.length">
|
|
104
|
+
<div v-for="group in groupedResources" :key="group.category" class="cpub-resources-group">
|
|
105
|
+
<h4 class="cpub-resources-category">
|
|
106
|
+
<i class="fa-solid" :class="{
|
|
107
|
+
'fa-book': group.category === 'documentation',
|
|
108
|
+
'fa-wrench': group.category === 'tools',
|
|
109
|
+
'fa-graduation-cap': group.category === 'tutorials',
|
|
110
|
+
'fa-users': group.category === 'community',
|
|
111
|
+
'fa-microchip': group.category === 'hardware',
|
|
112
|
+
'fa-code': group.category === 'software',
|
|
113
|
+
'fa-link': group.category === 'other',
|
|
114
|
+
}"></i>
|
|
115
|
+
{{ group.label }}
|
|
116
|
+
</h4>
|
|
117
|
+
<div class="cpub-resources-list">
|
|
118
|
+
<a
|
|
119
|
+
v-for="item in group.items"
|
|
120
|
+
:key="item.id"
|
|
121
|
+
:href="item.url"
|
|
122
|
+
target="_blank"
|
|
123
|
+
rel="noopener noreferrer"
|
|
124
|
+
class="cpub-resource-item"
|
|
125
|
+
>
|
|
126
|
+
<div class="cpub-resource-item-main">
|
|
127
|
+
<span class="cpub-resource-item-title">{{ item.title }}</span>
|
|
128
|
+
<i class="fa-solid fa-arrow-up-right-from-square cpub-resource-item-ext"></i>
|
|
129
|
+
<p v-if="item.description" class="cpub-resource-item-desc">{{ item.description }}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="cpub-resource-item-meta">
|
|
132
|
+
<span>{{ item.addedBy.displayName || item.addedBy.username }}</span>
|
|
133
|
+
<button
|
|
134
|
+
v-if="canManage || authUserId === item.addedBy.id"
|
|
135
|
+
class="cpub-resource-delete"
|
|
136
|
+
aria-label="Delete resource"
|
|
137
|
+
@click.prevent.stop="handleDelete(item.id)"
|
|
138
|
+
>
|
|
139
|
+
<i class="fa-solid fa-trash"></i>
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</a>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
146
|
+
<div v-else class="cpub-empty-state">
|
|
147
|
+
<div class="cpub-empty-state-icon"><i class="fa-solid fa-link"></i></div>
|
|
148
|
+
<p class="cpub-empty-state-title">No resources added yet</p>
|
|
149
|
+
<p class="cpub-empty-state-desc">Add links to documentation, tools, and tutorials for this community.</p>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</template>
|
|
153
|
+
|
|
154
|
+
<style scoped>
|
|
155
|
+
.cpub-resources-header {
|
|
156
|
+
margin-bottom: 16px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.cpub-resource-form {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
gap: 10px;
|
|
163
|
+
padding: 16px;
|
|
164
|
+
background: var(--surface);
|
|
165
|
+
border: var(--border-width-default) solid var(--border);
|
|
166
|
+
margin-bottom: 20px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.cpub-resources-group {
|
|
170
|
+
margin-bottom: 24px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.cpub-resources-category {
|
|
174
|
+
font-size: 11px;
|
|
175
|
+
font-family: var(--font-mono);
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
letter-spacing: 0.1em;
|
|
178
|
+
color: var(--text-faint);
|
|
179
|
+
margin-bottom: 8px;
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 6px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.cpub-resources-list {
|
|
186
|
+
display: flex;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
gap: 4px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.cpub-resource-item {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
justify-content: space-between;
|
|
195
|
+
gap: 12px;
|
|
196
|
+
padding: 10px 14px;
|
|
197
|
+
background: var(--surface);
|
|
198
|
+
border: var(--border-width-default) solid var(--border);
|
|
199
|
+
text-decoration: none;
|
|
200
|
+
color: inherit;
|
|
201
|
+
transition: box-shadow var(--transition-fast);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.cpub-resource-item:hover {
|
|
205
|
+
box-shadow: var(--shadow-sm);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.cpub-resource-item-main {
|
|
209
|
+
flex: 1;
|
|
210
|
+
min-width: 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.cpub-resource-item-title {
|
|
214
|
+
font-size: 13px;
|
|
215
|
+
font-weight: 600;
|
|
216
|
+
color: var(--text);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.cpub-resource-item-ext {
|
|
220
|
+
font-size: 9px;
|
|
221
|
+
color: var(--text-faint);
|
|
222
|
+
margin-left: 4px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.cpub-resource-item-desc {
|
|
226
|
+
font-size: 11px;
|
|
227
|
+
color: var(--text-dim);
|
|
228
|
+
margin-top: 2px;
|
|
229
|
+
white-space: nowrap;
|
|
230
|
+
overflow: hidden;
|
|
231
|
+
text-overflow: ellipsis;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.cpub-resource-item-meta {
|
|
235
|
+
font-size: 10px;
|
|
236
|
+
color: var(--text-faint);
|
|
237
|
+
font-family: var(--font-mono);
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
gap: 8px;
|
|
241
|
+
flex-shrink: 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.cpub-resource-delete {
|
|
245
|
+
background: none;
|
|
246
|
+
border: none;
|
|
247
|
+
cursor: pointer;
|
|
248
|
+
color: var(--text-faint);
|
|
249
|
+
padding: 4px;
|
|
250
|
+
font-size: 11px;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.cpub-resource-delete:hover {
|
|
254
|
+
color: var(--red, #ef4444);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@media (max-width: 640px) {
|
|
258
|
+
.cpub-resource-item {
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
align-items: flex-start;
|
|
261
|
+
gap: 6px;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"@commonpub/auth": "0.5.1",
|
|
58
58
|
"@commonpub/docs": "0.6.2",
|
|
59
59
|
"@commonpub/editor": "0.7.9",
|
|
60
|
-
"@commonpub/
|
|
60
|
+
"@commonpub/learning": "0.5.0",
|
|
61
61
|
"@commonpub/protocol": "0.9.9",
|
|
62
|
-
"@commonpub/
|
|
62
|
+
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -21,9 +21,14 @@ const hubType = computed(() => hub.value?.hubType ?? 'community');
|
|
|
21
21
|
const isProductHub = computed(() => hubType.value === 'product');
|
|
22
22
|
const isCompanyHub = computed(() => hubType.value === 'company');
|
|
23
23
|
|
|
24
|
-
const { data: products } = useLazyFetch<{ items: Array<{ id: string; name: string; description: string | null; imageUrl: string | null; category: string | null; status: string }>; total: number }>(
|
|
24
|
+
const { data: products, refresh: refreshProducts } = useLazyFetch<{ items: Array<{ id: string; name: string; description: string | null; imageUrl: string | null; category: string | null; status: string }>; total: number }>(
|
|
25
25
|
() => `/api/hubs/${slug.value}/products`,
|
|
26
|
-
{ default: () => ({ items: [], total: 0 })
|
|
26
|
+
{ default: () => ({ items: [], total: 0 }) },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const { data: resources, refresh: refreshResources } = useLazyFetch<{ items: Array<{ id: string; title: string; url: string; description: string | null; category: string; sortOrder: number; addedBy: { id: string; username: string; displayName: string | null; avatarUrl: string | null }; createdAt: string; updatedAt: string }>; total: number }>(
|
|
30
|
+
() => `/api/hubs/${slug.value}/resources`,
|
|
31
|
+
{ default: () => ({ items: [], total: 0 }) },
|
|
27
32
|
);
|
|
28
33
|
|
|
29
34
|
useSeoMeta({
|
|
@@ -126,6 +131,7 @@ const tabDefs = computed<HubTabDef[]>(() => {
|
|
|
126
131
|
{ value: 'overview', label: 'Overview', icon: 'fa-solid fa-info-circle' },
|
|
127
132
|
{ value: 'projects', label: 'Projects Using This', icon: 'fa-solid fa-folder-open', count: gallery.value?.total },
|
|
128
133
|
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
134
|
+
{ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
|
|
129
135
|
];
|
|
130
136
|
}
|
|
131
137
|
if (isCompanyHub.value) {
|
|
@@ -134,12 +140,15 @@ const tabDefs = computed<HubTabDef[]>(() => {
|
|
|
134
140
|
{ value: 'products', label: 'Products', icon: 'fa-solid fa-microchip', count: products.value?.total },
|
|
135
141
|
{ value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: gallery.value?.total },
|
|
136
142
|
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
143
|
+
{ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
|
|
137
144
|
];
|
|
138
145
|
}
|
|
139
146
|
return [
|
|
140
147
|
{ value: 'feed', label: 'Feed', icon: 'fa-solid fa-rss', count: hub.value?.postCount },
|
|
141
148
|
{ 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 },
|
|
142
150
|
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
151
|
+
{ value: 'resources', label: 'Resources', icon: 'fa-solid fa-link', count: resources.value?.total },
|
|
143
152
|
{ value: 'members', label: 'Members', icon: 'fa-solid fa-users', count: hub.value?.memberCount },
|
|
144
153
|
];
|
|
145
154
|
});
|
|
@@ -377,7 +386,10 @@ async function onRefreshGallery(): Promise<void> {
|
|
|
377
386
|
/>
|
|
378
387
|
|
|
379
388
|
<!-- Products tab -->
|
|
380
|
-
<HubProducts v-else-if="activeTab === 'products'" :products="products" />
|
|
389
|
+
<HubProducts v-else-if="activeTab === 'products'" :products="products" :current-user-role="hub?.currentUserRole ?? null" :hub-slug="slug" @product-created="refreshProducts" />
|
|
390
|
+
|
|
391
|
+
<!-- Resources tab -->
|
|
392
|
+
<HubResources v-else-if="activeTab === 'resources'" :resources="resources" :current-user-role="hub?.currentUserRole ?? null" :hub-slug="slug" :is-authenticated="isAuthenticated" :auth-user-id="authUser?.id ?? null" @resource-changed="refreshResources" />
|
|
381
393
|
|
|
382
394
|
<!-- Sidebar -->
|
|
383
395
|
<template #sidebar>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { deleteHubResource } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const db = useDB();
|
|
5
|
+
const user = requireAuth(event);
|
|
6
|
+
const id = getRouterParam(event, 'id');
|
|
7
|
+
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
|
|
8
|
+
|
|
9
|
+
const result = await deleteHubResource(db, id, user.id);
|
|
10
|
+
if (!result.success) {
|
|
11
|
+
const status = result.error?.includes('not found') ? 404 : 403;
|
|
12
|
+
throw createError({ statusCode: status, statusMessage: result.error ?? 'Delete failed' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return { success: true };
|
|
16
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { updateHubResource } from '@commonpub/server';
|
|
2
|
+
import { updateHubResourceSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const user = requireAuth(event);
|
|
7
|
+
const id = getRouterParam(event, 'id');
|
|
8
|
+
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
|
|
9
|
+
|
|
10
|
+
const input = await parseBody(event, updateHubResourceSchema);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return await updateHubResource(db, id, user.id, input);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
const message = err instanceof Error ? err.message : 'Update failed';
|
|
16
|
+
if (message.includes('not found')) throw createError({ statusCode: 404, statusMessage: message });
|
|
17
|
+
if (message.includes('permissions')) throw createError({ statusCode: 403, statusMessage: message });
|
|
18
|
+
throw createError({ statusCode: 500, statusMessage: message });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getHubBySlug, listHubResources } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const db = useDB();
|
|
5
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
6
|
+
|
|
7
|
+
const hub = await getHubBySlug(db, slug);
|
|
8
|
+
if (!hub) {
|
|
9
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return listHubResources(db, hub.id);
|
|
13
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getHubBySlug, createHubResource } from '@commonpub/server';
|
|
2
|
+
import { createHubResourceSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const user = requireAuth(event);
|
|
7
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
8
|
+
|
|
9
|
+
const hub = await getHubBySlug(db, slug, user.id);
|
|
10
|
+
if (!hub) {
|
|
11
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const input = await parseBody(event, createHubResourceSchema);
|
|
15
|
+
return createHubResource(db, hub.id, user.id, input);
|
|
16
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getHubBySlug, reorderHubResources } from '@commonpub/server';
|
|
2
|
+
import { reorderHubResourcesSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const user = requireAuth(event);
|
|
7
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
8
|
+
|
|
9
|
+
const hub = await getHubBySlug(db, slug, user.id);
|
|
10
|
+
if (!hub) {
|
|
11
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const input = await parseBody(event, reorderHubResourcesSchema);
|
|
15
|
+
const result = await reorderHubResources(db, hub.id, user.id, input.ids);
|
|
16
|
+
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
throw createError({ statusCode: 403, statusMessage: result.error ?? 'Reorder failed' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { success: true };
|
|
22
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getHubBySlug, listHubProducts } from '@commonpub/server';
|
|
2
|
+
import { AP_CONTEXT } from '@commonpub/protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hub products collection endpoint. Returns AP OrderedCollection for federation.
|
|
6
|
+
* Only responds to ActivityPub clients (Accept: application/activity+json).
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
10
|
+
const isAPRequest =
|
|
11
|
+
accept.includes('application/activity+json') ||
|
|
12
|
+
accept.includes('application/ld+json');
|
|
13
|
+
|
|
14
|
+
if (!isAPRequest) return;
|
|
15
|
+
|
|
16
|
+
const config = useConfig();
|
|
17
|
+
if (!config.features.federation || !config.features.federateHubs) return;
|
|
18
|
+
|
|
19
|
+
const slug = getRouterParam(event, 'slug');
|
|
20
|
+
if (!slug) return;
|
|
21
|
+
|
|
22
|
+
const db = useDB();
|
|
23
|
+
const hub = await getHubBySlug(db, slug);
|
|
24
|
+
if (!hub) return;
|
|
25
|
+
|
|
26
|
+
const { items } = await listHubProducts(db, hub.id);
|
|
27
|
+
const domain = config.instance.domain;
|
|
28
|
+
const collectionUri = `https://${domain}/hubs/${slug}/products`;
|
|
29
|
+
|
|
30
|
+
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
31
|
+
return {
|
|
32
|
+
'@context': AP_CONTEXT,
|
|
33
|
+
type: 'OrderedCollection',
|
|
34
|
+
id: collectionUri,
|
|
35
|
+
totalItems: items.length,
|
|
36
|
+
orderedItems: items.map((item) => ({
|
|
37
|
+
type: 'cpub:Product',
|
|
38
|
+
id: `https://${domain}/products/${item.slug}`,
|
|
39
|
+
name: item.name,
|
|
40
|
+
summary: item.description ?? undefined,
|
|
41
|
+
url: item.purchaseUrl ?? `https://${domain}/products/${item.slug}`,
|
|
42
|
+
...(item.imageUrl ? { image: { type: 'Image', url: item.imageUrl } } : {}),
|
|
43
|
+
'cpub:category': item.category ?? undefined,
|
|
44
|
+
'cpub:status': item.status,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getHubBySlug, listHubResources } from '@commonpub/server';
|
|
2
|
+
import { AP_CONTEXT } from '@commonpub/protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hub resources collection endpoint. Returns AP OrderedCollection for federation.
|
|
6
|
+
* Only responds to ActivityPub clients (Accept: application/activity+json).
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
10
|
+
const isAPRequest =
|
|
11
|
+
accept.includes('application/activity+json') ||
|
|
12
|
+
accept.includes('application/ld+json');
|
|
13
|
+
|
|
14
|
+
if (!isAPRequest) return;
|
|
15
|
+
|
|
16
|
+
const config = useConfig();
|
|
17
|
+
if (!config.features.federation || !config.features.federateHubs) return;
|
|
18
|
+
|
|
19
|
+
const slug = getRouterParam(event, 'slug');
|
|
20
|
+
if (!slug) return;
|
|
21
|
+
|
|
22
|
+
const db = useDB();
|
|
23
|
+
const hub = await getHubBySlug(db, slug);
|
|
24
|
+
if (!hub) return;
|
|
25
|
+
|
|
26
|
+
const { items } = await listHubResources(db, hub.id);
|
|
27
|
+
const domain = config.instance.domain;
|
|
28
|
+
const collectionUri = `https://${domain}/hubs/${slug}/resources`;
|
|
29
|
+
|
|
30
|
+
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
31
|
+
return {
|
|
32
|
+
'@context': AP_CONTEXT,
|
|
33
|
+
type: 'OrderedCollection',
|
|
34
|
+
id: collectionUri,
|
|
35
|
+
totalItems: items.length,
|
|
36
|
+
orderedItems: items.map((item) => ({
|
|
37
|
+
type: 'cpub:Resource',
|
|
38
|
+
id: `${collectionUri}/${item.id}`,
|
|
39
|
+
name: item.title,
|
|
40
|
+
url: item.url,
|
|
41
|
+
summary: item.description ?? undefined,
|
|
42
|
+
'cpub:category': item.category,
|
|
43
|
+
'cpub:sortOrder': item.sortOrder,
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
});
|