@commonpub/layer 0.8.8 → 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.
@@ -1,10 +1,70 @@
1
1
  <script setup lang="ts">
2
- defineProps<{
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>
@@ -275,7 +275,8 @@ useJsonLd({
275
275
  class="cpub-related-card"
276
276
  >
277
277
  <div class="cpub-related-card-thumb">
278
- <i class="fa-solid fa-newspaper"></i>
278
+ <img v-if="(item as any).coverImageUrl" :src="(item as any).coverImageUrl" :alt="item.title" class="cpub-related-card-img" />
279
+ <i v-else class="fa-solid fa-newspaper"></i>
279
280
  </div>
280
281
  <div class="cpub-related-card-body">
281
282
  <div class="cpub-related-card-type">{{ item.type }}</div>
@@ -911,6 +912,14 @@ useJsonLd({
911
912
  opacity: 0.3;
912
913
  }
913
914
 
915
+ .cpub-related-card-img {
916
+ width: 100%;
917
+ height: 100%;
918
+ object-fit: cover;
919
+ position: relative;
920
+ z-index: 1;
921
+ }
922
+
914
923
  .cpub-related-card-thumb i {
915
924
  font-size: 22px;
916
925
  color: var(--text-faint);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -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/docs": "0.6.2",
57
56
  "@commonpub/config": "0.9.1",
57
+ "@commonpub/auth": "0.5.1",
58
+ "@commonpub/docs": "0.6.2",
58
59
  "@commonpub/editor": "0.7.9",
59
60
  "@commonpub/learning": "0.5.0",
60
- "@commonpub/auth": "0.5.1",
61
- "@commonpub/ui": "0.8.5",
62
- "@commonpub/protocol": "0.9.9"
61
+ "@commonpub/protocol": "0.9.9",
62
+ "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
package/pages/explore.vue CHANGED
@@ -539,9 +539,12 @@ const sortOptions = [
539
539
  padding: 24px 0;
540
540
  }
541
541
 
542
+ @media (max-width: 1024px) {
543
+ .cpub-explore-hub-grid { grid-template-columns: 1fr; }
544
+ }
545
+
542
546
  @media (max-width: 768px) {
543
547
  .cpub-explore-grid { grid-template-columns: 1fr; }
544
- .cpub-explore-hub-grid { grid-template-columns: 1fr; }
545
548
  .cpub-explore-filters { flex-wrap: wrap; }
546
549
  }
547
550
  </style>
@@ -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 }), immediate: isCompanyHub.value },
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>
@@ -33,13 +33,18 @@ export default defineEventHandler(async (event) => {
33
33
  }
34
34
 
35
35
  // Proxy to Better Auth's email sign-in (internal server-side call)
36
- const origin = getRequestURL(event).origin;
36
+ // Forward Origin + Referer so Better Auth's CSRF protection accepts the request
37
+ const requestUrl = getRequestURL(event);
38
+ const origin = requestUrl.origin;
39
+ const clientOrigin = getRequestHeader(event, 'origin') || origin;
37
40
  const response = await $fetch.raw(`${origin}/api/auth/sign-in/email`, {
38
41
  method: 'POST',
39
42
  body: { email, password: body.password },
40
43
  headers: {
41
44
  'Content-Type': 'application/json',
42
45
  Cookie: getRequestHeader(event, 'cookie') ?? '',
46
+ Origin: clientOrigin,
47
+ Referer: getRequestHeader(event, 'referer') || `${clientOrigin}/auth/login`,
43
48
  },
44
49
  });
45
50
 
@@ -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
+ });