@commonpub/layer 0.8.9 → 0.9.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.
@@ -18,7 +18,7 @@ onMounted(() => {
18
18
  </script>
19
19
 
20
20
  <template>
21
- <div class="cpub-block-checkpoint" :class="{ visible: completed }">
21
+ <div class="cpub-block-checkpoint" :class="{ visible: completed }" role="status" aria-live="polite" aria-atomic="true">
22
22
  <i class="fa-solid fa-circle-check"></i>
23
23
  <span class="cpub-checkpoint-text">{{ label }}</span>
24
24
  </div>
@@ -77,7 +77,7 @@ function optionClass(idx: number): string {
77
77
  </button>
78
78
  </div>
79
79
 
80
- <div v-if="answered" class="cpub-quiz-feedback" :class="isCorrect ? 'correct' : 'wrong'">
80
+ <div v-if="answered" class="cpub-quiz-feedback" :class="isCorrect ? 'correct' : 'wrong'" role="status" aria-live="polite" aria-atomic="true">
81
81
  <i :class="isCorrect ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-xmark'"></i>
82
82
  <span>{{ isCorrect ? 'Correct!' : 'Not quite — the correct answer is highlighted above.' }}</span>
83
83
  </div>
@@ -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>
@@ -147,6 +147,14 @@ watch(activeSection, () => {
147
147
  // Scroll section viewport to top on section change
148
148
  const viewport = document.querySelector('.cpub-section-viewport');
149
149
  if (viewport) viewport.scrollTop = 0;
150
+ // Focus section heading for screen reader announcement
151
+ nextTick(() => {
152
+ const heading = viewport?.querySelector('h1, h2, h3') as HTMLElement | null;
153
+ if (heading) {
154
+ heading.setAttribute('tabindex', '-1');
155
+ heading.focus();
156
+ }
157
+ });
150
158
  });
151
159
 
152
160
  // Current section data
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.8.9",
3
+ "version": "0.9.1",
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/config": "0.9.1",
57
56
  "@commonpub/auth": "0.5.1",
58
57
  "@commonpub/docs": "0.6.2",
58
+ "@commonpub/config": "0.9.1",
59
59
  "@commonpub/editor": "0.7.9",
60
- "@commonpub/ui": "0.8.5",
60
+ "@commonpub/learning": "0.5.0",
61
61
  "@commonpub/protocol": "0.9.9",
62
- "@commonpub/learning": "0.5.0"
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 }), 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>
@@ -4,41 +4,39 @@ definePageMeta({ middleware: 'auth' });
4
4
  const { show: toast } = useToast();
5
5
  const saving = ref(false);
6
6
 
7
- const prefs = ref({
8
- emailLikes: true,
9
- emailComments: true,
10
- emailFollows: true,
11
- emailMentions: true,
12
- emailDigest: false,
13
- });
7
+ const likes = ref(true);
8
+ const comments = ref(true);
9
+ const follows = ref(true);
10
+ const mentions = ref(true);
11
+ const digest = ref<'none' | 'daily' | 'weekly'>('none');
14
12
 
15
13
  // Load current preferences from profile
16
14
  import type { Serialized, UserProfile } from '@commonpub/server';
17
15
 
18
- const { data: profile, pending } = await useFetch<Serialized<UserProfile> & { notificationPrefs?: Record<string, boolean> }>('/api/profile');
19
- if (profile.value?.notificationPrefs) {
20
- const saved = profile.value.notificationPrefs;
21
- for (const key of Object.keys(prefs.value)) {
22
- if (key in saved) {
23
- (prefs.value as Record<string, boolean>)[key] = saved[key];
24
- }
25
- }
16
+ const { data: profile, pending } = await useFetch<Serialized<UserProfile> & { emailNotifications?: { digest?: string; likes?: boolean; comments?: boolean; follows?: boolean; mentions?: boolean } }>('/api/profile');
17
+ if (profile.value?.emailNotifications) {
18
+ const saved = profile.value.emailNotifications;
19
+ if (saved.likes !== undefined) likes.value = saved.likes;
20
+ if (saved.comments !== undefined) comments.value = saved.comments;
21
+ if (saved.follows !== undefined) follows.value = saved.follows;
22
+ if (saved.mentions !== undefined) mentions.value = saved.mentions;
23
+ if (saved.digest) digest.value = saved.digest as 'none' | 'daily' | 'weekly';
26
24
  }
27
25
 
28
- const labels: Record<string, string> = {
29
- emailLikes: 'Email when someone likes your content',
30
- emailComments: 'Email when someone comments on your content',
31
- emailFollows: 'Email when someone follows you',
32
- emailMentions: 'Email when someone mentions you',
33
- emailDigest: 'Weekly digest email',
34
- };
35
-
36
26
  async function handleSave(): Promise<void> {
37
27
  saving.value = true;
38
28
  try {
39
29
  await $fetch('/api/profile', {
40
30
  method: 'PUT',
41
- body: { notificationPrefs: prefs.value },
31
+ body: {
32
+ emailNotifications: {
33
+ likes: likes.value,
34
+ comments: comments.value,
35
+ follows: follows.value,
36
+ mentions: mentions.value,
37
+ digest: digest.value,
38
+ },
39
+ },
42
40
  });
43
41
  toast('Preferences saved', 'success');
44
42
  } catch (err: unknown) {
@@ -59,11 +57,32 @@ async function handleSave(): Promise<void> {
59
57
  </div>
60
58
 
61
59
  <template v-else>
62
- <div v-for="(val, key) in prefs" :key="key" style="margin-bottom: 12px">
63
- <label class="cpub-checkbox">
64
- <input type="checkbox" v-model="prefs[key as keyof typeof prefs]" />
65
- {{ labels[key] || key }}
66
- </label>
60
+ <div class="cpub-prefs-section">
61
+ <h3 class="cpub-section-subtitle">Email Notifications</h3>
62
+ <div class="cpub-pref-row">
63
+ <label class="cpub-checkbox"><input type="checkbox" v-model="likes" /> Email when someone likes your content</label>
64
+ </div>
65
+ <div class="cpub-pref-row">
66
+ <label class="cpub-checkbox"><input type="checkbox" v-model="comments" /> Email when someone comments on your content</label>
67
+ </div>
68
+ <div class="cpub-pref-row">
69
+ <label class="cpub-checkbox"><input type="checkbox" v-model="follows" /> Email when someone follows you</label>
70
+ </div>
71
+ <div class="cpub-pref-row">
72
+ <label class="cpub-checkbox"><input type="checkbox" v-model="mentions" /> Email when someone mentions you</label>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="cpub-prefs-section">
77
+ <h3 class="cpub-section-subtitle">Digest</h3>
78
+ <div class="cpub-pref-row">
79
+ <label for="digest-select">Summary email frequency</label>
80
+ <select id="digest-select" v-model="digest" class="cpub-input" style="max-width: 200px;">
81
+ <option value="none">None</option>
82
+ <option value="daily">Daily (8am UTC)</option>
83
+ <option value="weekly">Weekly (Monday 8am UTC)</option>
84
+ </select>
85
+ </div>
67
86
  </div>
68
87
 
69
88
  <button class="cpub-btn cpub-btn-primary cpub-btn-sm" style="margin-top: 16px" :disabled="saving" @click="handleSave">
@@ -72,3 +91,9 @@ async function handleSave(): Promise<void> {
72
91
  </template>
73
92
  </div>
74
93
  </template>
94
+
95
+ <style scoped>
96
+ .cpub-prefs-section { margin-bottom: 24px; }
97
+ .cpub-section-subtitle { font-size: 13px; font-weight: 700; margin-bottom: 12px; color: var(--text); }
98
+ .cpub-pref-row { margin-bottom: 10px; }
99
+ </style>
@@ -0,0 +1,56 @@
1
+ import { indexContent, configureContentIndex } from '@commonpub/server';
2
+ import { contentItems } from '@commonpub/schema';
3
+ import { eq } from 'drizzle-orm';
4
+ import type { MeiliClient } from '@commonpub/server';
5
+
6
+ /**
7
+ * Rebuild the entire Meilisearch content index.
8
+ * Iterates all published content and re-indexes each item.
9
+ * Requires admin role. Rate-limited to prevent abuse.
10
+ */
11
+ export default defineEventHandler(async (event) => {
12
+ const user = requireAuth(event);
13
+ requireAdmin(event);
14
+
15
+ const meiliUrl = process.env.MEILI_URL;
16
+ const meiliKey = process.env.MEILI_MASTER_KEY;
17
+ if (!meiliUrl) {
18
+ throw createError({ statusCode: 400, statusMessage: 'Meilisearch not configured' });
19
+ }
20
+
21
+ const db = useDB();
22
+
23
+ let client: MeiliClient;
24
+ try {
25
+ const { MeiliSearch } = await import('meilisearch');
26
+ client = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey }) as unknown as MeiliClient;
27
+ await configureContentIndex(client);
28
+ } catch (err) {
29
+ throw createError({ statusCode: 503, statusMessage: 'Failed to connect to Meilisearch' });
30
+ }
31
+
32
+ // Fetch all published content IDs
33
+ const published = await db
34
+ .select({ id: contentItems.id })
35
+ .from(contentItems)
36
+ .where(eq(contentItems.status, 'published'));
37
+
38
+ let indexed = 0;
39
+ let errors = 0;
40
+
41
+ for (const item of published) {
42
+ try {
43
+ await indexContent(db, item.id, client);
44
+ indexed++;
45
+ } catch {
46
+ errors++;
47
+ }
48
+ }
49
+
50
+ return {
51
+ success: true,
52
+ indexed,
53
+ errors,
54
+ total: published.length,
55
+ };
56
+ });
@@ -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
+ });