@commonpub/layer 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ attachments: Array<{ type: string; url: string; name?: string }>;
4
+ }>();
5
+
6
+ function iconForType(type: string): string {
7
+ if (type === 'Image') return 'fa-solid fa-image';
8
+ if (type === 'Video') return 'fa-solid fa-film';
9
+ if (type === 'Audio') return 'fa-solid fa-music';
10
+ return 'fa-solid fa-file';
11
+ }
12
+
13
+ function fileName(att: { url: string; name?: string }): string {
14
+ if (att.name) return att.name;
15
+ try {
16
+ return new URL(att.url).pathname.split('/').pop() ?? 'attachment';
17
+ } catch {
18
+ return 'attachment';
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div v-if="attachments.length > 0" class="cpub-attachments">
25
+ <div class="cpub-attachments-label">Attachments</div>
26
+ <div class="cpub-attachments-list">
27
+ <a
28
+ v-for="(att, i) in attachments"
29
+ :key="i"
30
+ :href="att.url"
31
+ target="_blank"
32
+ rel="noopener noreferrer"
33
+ class="cpub-attachment-item"
34
+ >
35
+ <img v-if="att.type === 'Image'" :src="att.url" :alt="att.name ?? ''" class="cpub-attachment-thumb" loading="lazy" />
36
+ <div v-else class="cpub-attachment-icon"><i :class="iconForType(att.type)"></i></div>
37
+ <span class="cpub-attachment-name">{{ fileName(att) }}</span>
38
+ </a>
39
+ </div>
40
+ </div>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .cpub-attachments {
45
+ margin: 28px 0;
46
+ padding-top: 16px;
47
+ border-top: 1px solid var(--border);
48
+ }
49
+ .cpub-attachments-label {
50
+ font-size: 10px;
51
+ font-family: var(--font-mono);
52
+ color: var(--text-faint);
53
+ letter-spacing: 0.1em;
54
+ text-transform: uppercase;
55
+ margin-bottom: 10px;
56
+ }
57
+ .cpub-attachments-list {
58
+ display: flex;
59
+ flex-wrap: wrap;
60
+ gap: 8px;
61
+ }
62
+ .cpub-attachment-item {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 8px;
66
+ padding: 8px 12px;
67
+ border: 1px solid var(--border);
68
+ background: var(--surface);
69
+ text-decoration: none;
70
+ color: var(--text-dim);
71
+ font-size: 12px;
72
+ transition: background var(--transition-fast);
73
+ }
74
+ .cpub-attachment-item:hover {
75
+ background: var(--surface2);
76
+ color: var(--accent);
77
+ }
78
+ .cpub-attachment-thumb {
79
+ width: 40px;
80
+ height: 40px;
81
+ object-fit: cover;
82
+ border: 1px solid var(--border2);
83
+ }
84
+ .cpub-attachment-icon {
85
+ width: 32px;
86
+ height: 32px;
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ background: var(--surface2);
91
+ border: 1px solid var(--border2);
92
+ color: var(--text-faint);
93
+ font-size: 14px;
94
+ }
95
+ .cpub-attachment-name {
96
+ font-family: var(--font-mono);
97
+ max-width: 200px;
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ white-space: nowrap;
101
+ }
102
+ </style>
@@ -2,6 +2,7 @@
2
2
  const props = defineProps<{
3
3
  title: string;
4
4
  author: string;
5
+ authorAvatarUrl?: string | null;
5
6
  replyCount: number;
6
7
  voteCount: number;
7
8
  lastReplyAt?: Date;
@@ -50,6 +51,8 @@ const lastReplyFormatted = computed((): string | null => {
50
51
  </span>
51
52
  </div>
52
53
  <div class="cpub-discussion-meta">
54
+ <img v-if="authorAvatarUrl" :src="authorAvatarUrl" :alt="author" class="cpub-discussion-avatar" />
55
+ <div v-else class="cpub-discussion-avatar cpub-discussion-avatar-fallback">{{ author.charAt(0).toUpperCase() }}</div>
53
56
  <span class="cpub-discussion-author">{{ author }}</span>
54
57
  <span class="cpub-discussion-sep" aria-hidden="true">&middot;</span>
55
58
  <span class="cpub-discussion-replies">
@@ -170,6 +173,25 @@ const lastReplyFormatted = computed((): string | null => {
170
173
  flex-wrap: wrap;
171
174
  }
172
175
 
176
+ .cpub-discussion-avatar {
177
+ width: 16px;
178
+ height: 16px;
179
+ object-fit: cover;
180
+ border: 1px solid var(--border);
181
+ flex-shrink: 0;
182
+ }
183
+
184
+ .cpub-discussion-avatar-fallback {
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ background: var(--surface2);
189
+ font-family: var(--font-mono);
190
+ font-size: 8px;
191
+ font-weight: 600;
192
+ color: var(--text-dim);
193
+ }
194
+
173
195
  .cpub-discussion-author {
174
196
  font-weight: var(--font-weight-medium);
175
197
  color: var(--text-dim);
@@ -3,6 +3,8 @@ defineProps<{
3
3
  messages: Array<{
4
4
  id: string;
5
5
  senderId: string;
6
+ senderName?: string | null;
7
+ senderAvatarUrl?: string | null;
6
8
  body: string;
7
9
  createdAt: string;
8
10
  }>;
@@ -31,6 +33,11 @@ function handleSend(): void {
31
33
  class="cpub-msg"
32
34
  :class="{ own: msg.senderId === currentUserId }"
33
35
  >
36
+ <div v-if="msg.senderId !== currentUserId" class="cpub-msg-sender">
37
+ <img v-if="msg.senderAvatarUrl" :src="msg.senderAvatarUrl" :alt="msg.senderName ?? ''" class="cpub-msg-avatar" />
38
+ <div v-else class="cpub-msg-avatar cpub-msg-avatar-fallback">{{ (msg.senderName ?? '?').charAt(0).toUpperCase() }}</div>
39
+ <span v-if="msg.senderName" class="cpub-msg-name">{{ msg.senderName }}</span>
40
+ </div>
34
41
  <div class="cpub-msg-bubble">{{ msg.body }}</div>
35
42
  <time class="cpub-msg-time">
36
43
  {{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
@@ -78,6 +85,38 @@ function handleSend(): void {
78
85
  align-self: flex-end;
79
86
  }
80
87
 
88
+ .cpub-msg-sender {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 6px;
92
+ margin-bottom: 3px;
93
+ }
94
+
95
+ .cpub-msg-avatar {
96
+ width: 20px;
97
+ height: 20px;
98
+ object-fit: cover;
99
+ border: 1px solid var(--border);
100
+ flex-shrink: 0;
101
+ }
102
+
103
+ .cpub-msg-avatar-fallback {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ background: var(--surface2);
108
+ font-family: var(--font-mono);
109
+ font-size: 9px;
110
+ font-weight: 600;
111
+ color: var(--text-dim);
112
+ }
113
+
114
+ .cpub-msg-name {
115
+ font-size: 11px;
116
+ font-weight: 600;
117
+ color: var(--text-dim);
118
+ }
119
+
81
120
  .cpub-msg-bubble {
82
121
  padding: 8px 12px;
83
122
  font-size: 13px;
@@ -5,6 +5,7 @@ defineProps<{
5
5
  type: string;
6
6
  message: string;
7
7
  actorName?: string | null;
8
+ actorAvatarUrl?: string | null;
8
9
  link?: string | null;
9
10
  targetUrl?: string;
10
11
  read: boolean;
@@ -23,8 +24,14 @@ const iconMap: Record<string, string> = {
23
24
 
24
25
  <template>
25
26
  <div class="cpub-notif" :class="{ unread: !notification.read }">
26
- <div class="cpub-notif-icon">
27
- <i :class="iconMap[notification.type] || 'fa-solid fa-bell'"></i>
27
+ <div class="cpub-notif-avatar-wrap">
28
+ <img v-if="notification.actorAvatarUrl" :src="notification.actorAvatarUrl" :alt="notification.actorName ?? ''" class="cpub-notif-avatar" />
29
+ <div v-else class="cpub-notif-avatar cpub-notif-avatar-fallback">
30
+ {{ (notification.actorName ?? '?').charAt(0).toUpperCase() }}
31
+ </div>
32
+ <div class="cpub-notif-icon-badge">
33
+ <i :class="iconMap[notification.type] || 'fa-solid fa-bell'"></i>
34
+ </div>
28
35
  </div>
29
36
  <div class="cpub-notif-body">
30
37
  <p class="cpub-notif-text">
@@ -56,17 +63,45 @@ const iconMap: Record<string, string> = {
56
63
  border-color: var(--accent-border);
57
64
  }
58
65
 
59
- .cpub-notif-icon {
60
- width: 28px;
61
- height: 28px;
66
+ .cpub-notif-avatar-wrap {
67
+ position: relative;
68
+ width: 32px;
69
+ height: 32px;
70
+ flex-shrink: 0;
71
+ }
72
+
73
+ .cpub-notif-avatar {
74
+ width: 32px;
75
+ height: 32px;
76
+ object-fit: cover;
77
+ border: var(--border-width-default) solid var(--border);
78
+ }
79
+
80
+ .cpub-notif-avatar-fallback {
62
81
  display: flex;
63
82
  align-items: center;
64
83
  justify-content: center;
65
84
  background: var(--surface2);
66
- border: var(--border-width-default) solid var(--border);
67
- font-size: 11px;
85
+ font-family: var(--font-mono);
86
+ font-size: 12px;
87
+ font-weight: 600;
88
+ color: var(--text-dim);
89
+ }
90
+
91
+ .cpub-notif-icon-badge {
92
+ position: absolute;
93
+ bottom: -3px;
94
+ right: -3px;
95
+ width: 16px;
96
+ height: 16px;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ background: var(--surface);
101
+ border: 1px solid var(--border);
102
+ border-radius: 50%;
103
+ font-size: 8px;
68
104
  color: var(--text-dim);
69
- flex-shrink: 0;
70
105
  }
71
106
 
72
107
  .cpub-notif-body {
@@ -225,6 +225,9 @@ useJsonLd({
225
225
  </NuxtLink>
226
226
  </div>
227
227
 
228
+ <!-- ATTACHMENTS -->
229
+ <ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
230
+
228
231
  <!-- COMMENTS SECTION -->
229
232
  <CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
230
233
 
@@ -43,6 +43,12 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
43
43
 
44
44
  <template>
45
45
  <div class="cpub-blog-view">
46
+
47
+ <!-- COVER IMAGE -->
48
+ <div v-if="content.coverImageUrl" class="cpub-cover">
49
+ <img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-img" />
50
+ </div>
51
+
46
52
  <div class="cpub-blog-wrap">
47
53
 
48
54
  <!-- TYPE BADGE -->
@@ -159,7 +165,8 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
159
165
 
160
166
  <!-- AUTHOR CARD -->
161
167
  <div v-if="content.author" class="cpub-author-card">
162
- <div class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
168
+ <img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName ?? content.author.username ?? ''" class="cpub-av cpub-av-xl" style="object-fit:cover;border:2px solid var(--border);" />
169
+ <div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
163
170
  <div class="cpub-author-card-info">
164
171
  <div class="cpub-author-card-label">Posted by</div>
165
172
  <div class="cpub-author-card-name">{{ content.author.displayName || content.author.username }}</div>
@@ -175,6 +182,9 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
175
182
  </div>
176
183
  </div>
177
184
 
185
+ <!-- ATTACHMENTS -->
186
+ <ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
187
+
178
188
  <!-- COMMENTS -->
179
189
  <CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
180
190
 
@@ -183,6 +193,23 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
183
193
  </template>
184
194
 
185
195
  <style scoped>
196
+ /* ── COVER IMAGE ── */
197
+ .cpub-cover {
198
+ width: 100%;
199
+ max-height: 420px;
200
+ overflow: hidden;
201
+ border-bottom: var(--border-width-default) solid var(--border);
202
+ background: var(--surface);
203
+ }
204
+
205
+ .cpub-cover-img {
206
+ width: 100%;
207
+ height: 100%;
208
+ max-height: 420px;
209
+ object-fit: cover;
210
+ display: block;
211
+ }
212
+
186
213
  /* ── BLOG WRAP ── */
187
214
  .cpub-blog-wrap {
188
215
  max-width: 720px;
@@ -240,6 +240,11 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
240
240
  <main class="cpub-explainer-main">
241
241
  <div class="cpub-section-viewport">
242
242
  <div class="cpub-content-wrap" :key="activeSection">
243
+ <!-- Cover image (first section only) -->
244
+ <div v-if="activeSection === 0 && content.coverImageUrl" class="cpub-explainer-cover">
245
+ <img :src="content.coverImageUrl" :alt="content.title" />
246
+ </div>
247
+
243
248
  <!-- Section Header (from sectionHeader block data) -->
244
249
  <div v-if="currentSection?.tag" class="cpub-section-tag">{{ currentSection.tag }}</div>
245
250
  <h1 class="cpub-section-title">{{ currentSection?.title || content.title }}</h1>
@@ -546,6 +551,22 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
546
551
  min-height: calc(100vh - 51px - 80px);
547
552
  }
548
553
 
554
+ /* ── COVER IMAGE ── */
555
+ .cpub-explainer-cover {
556
+ width: 100%;
557
+ max-height: 320px;
558
+ overflow: hidden;
559
+ margin-bottom: 20px;
560
+ border: var(--border-width-default) solid var(--border);
561
+ }
562
+ .cpub-explainer-cover img {
563
+ width: 100%;
564
+ height: 100%;
565
+ max-height: 320px;
566
+ object-fit: cover;
567
+ display: block;
568
+ }
569
+
549
570
  /* ── SECTION TAG ── */
550
571
  .cpub-section-tag {
551
572
  font-family: var(--font-mono);
@@ -434,6 +434,9 @@ async function handleBuild(): Promise<void> {
434
434
  </div>
435
435
  </template>
436
436
  </div>
437
+
438
+ <!-- ATTACHMENTS -->
439
+ <ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
437
440
  </template>
438
441
 
439
442
  <!-- BOM / PARTS & STEPS TAB -->
@@ -82,6 +82,7 @@ export interface ContentViewData {
82
82
  seriesTotalParts?: number;
83
83
  seriesPrev?: { title: string; url: string };
84
84
  seriesNext?: { title: string; url: string };
85
+ attachments?: Array<{ type: string; url: string; name?: string }>;
85
86
  }
86
87
 
87
88
  export interface EngagementOptions {
@@ -83,6 +83,7 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
83
83
  },
84
84
  buildCount: 0,
85
85
  bookmarkCount: 0,
86
+ attachments: Array.isArray(fc.attachments) ? (fc.attachments as Array<{ type: string; url: string; name?: string }>) : [],
86
87
  } satisfies ContentViewData;
87
88
  });
88
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -47,12 +47,12 @@
47
47
  "@commonpub/auth": "0.5.0",
48
48
  "@commonpub/config": "0.7.0",
49
49
  "@commonpub/docs": "0.5.2",
50
- "@commonpub/learning": "0.5.0",
51
50
  "@commonpub/editor": "0.5.0",
51
+ "@commonpub/learning": "0.5.0",
52
52
  "@commonpub/protocol": "0.9.4",
53
- "@commonpub/schema": "0.8.10",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/server": "2.9.0"
53
+ "@commonpub/schema": "0.8.11",
54
+ "@commonpub/server": "2.11.0",
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -208,11 +208,12 @@ async function deleteItem(id: string, title: string): Promise<void> {
208
208
  <div class="cpub-dash-list">
209
209
  <div v-for="bm in bookmarkData?.items ?? []" :key="bm.id" class="cpub-dash-row">
210
210
  <template v-if="bm.content">
211
- <NuxtLink :to="`/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
211
+ <NuxtLink :to="bm.isFederated ? `/mirror/${bm.targetId}` : `/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
212
212
  {{ bm.content.title }}
213
213
  </NuxtLink>
214
214
  <span class="cpub-dash-row-meta">
215
215
  <ContentTypeBadge :type="bm.content.type" />
216
+ <span v-if="bm.isFederated && bm.content.originDomain" class="cpub-dash-row-fed"><i class="fa-solid fa-globe"></i> {{ bm.content.originDomain }}</span>
216
217
  <span v-if="bm.content.author">by {{ bm.content.author.displayName || bm.content.author.username }}</span>
217
218
  </span>
218
219
  </template>
@@ -175,14 +175,14 @@ if (hub.value?.url) {
175
175
  </p>
176
176
  </div>
177
177
 
178
- <!-- Empty state for replies -->
178
+ <!-- Reply thread info -->
179
179
  <div class="cpub-replies-section">
180
180
  <div class="cpub-empty-state" style="padding: 32px 16px">
181
- <p class="cpub-empty-state-title">Replies are on the origin instance</p>
181
+ <p class="cpub-empty-state-title"><i class="fa-solid fa-globe"></i> Federated thread</p>
182
182
  <p class="cpub-empty-state-desc">
183
- Reply threads live on <strong>{{ hub?.originDomain }}</strong>.
183
+ Replies sent here are delivered to <strong>{{ hub?.originDomain }}</strong> via ActivityPub.
184
184
  <a v-if="post.objectUri" :href="post.objectUri" target="_blank" rel="noopener noreferrer" class="cpub-inline-link">
185
- View thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
185
+ View full thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
186
186
  </a>
187
187
  </p>
188
188
  </div>
@@ -7,10 +7,34 @@ const route = useRoute();
7
7
  const handle = computed(() => decodeURIComponent(route.params.handle as string));
8
8
 
9
9
  const { searchResult, searchLoading, searchError, searchRemoteUser, followRemoteUser, unfollowRemoteUser } = useFederation();
10
+ const { user } = useAuth();
10
11
 
11
12
  const content = ref<FederatedContentItem[]>([]);
12
13
  const contentLoading = ref(false);
13
14
 
15
+ // DM state
16
+ const showDmForm = ref(false);
17
+ const dmBody = ref('');
18
+ const dmSending = ref(false);
19
+ const dmSent = ref(false);
20
+
21
+ async function sendDm(): Promise<void> {
22
+ if (!dmBody.value.trim() || !searchResult.value) return;
23
+ dmSending.value = true;
24
+ try {
25
+ const remoteHandle = `@${searchResult.value.preferredUsername}@${searchResult.value.instanceDomain}`;
26
+ await $fetch('/api/federation/dm', { method: 'POST', body: { handle: remoteHandle, body: dmBody.value } });
27
+ dmSent.value = true;
28
+ dmBody.value = '';
29
+ showDmForm.value = false;
30
+ setTimeout(() => { dmSent.value = false; }, 5000);
31
+ } catch {
32
+ // TODO: show error toast
33
+ } finally {
34
+ dmSending.value = false;
35
+ }
36
+ }
37
+
14
38
  async function loadProfile() {
15
39
  const h = handle.value.startsWith('@') ? handle.value : `@${handle.value}`;
16
40
  await searchRemoteUser(h);
@@ -75,17 +99,45 @@ function stripHtml(html: string): string {
75
99
  </div>
76
100
  </div>
77
101
 
78
- <button
79
- class="cpub-remote-profile__follow-btn"
80
- :class="{
81
- 'cpub-remote-profile__follow-btn--following': searchResult.isFollowing,
82
- 'cpub-remote-profile__follow-btn--pending': searchResult.isFollowPending,
83
- }"
84
- :disabled="searchResult.isFollowPending"
85
- @click="searchResult.isFollowing ? onUnfollow() : onFollow()"
86
- >
87
- {{ searchResult.isFollowing ? 'Following' : searchResult.isFollowPending ? 'Pending' : 'Follow' }}
88
- </button>
102
+ <div class="cpub-remote-profile__actions">
103
+ <button
104
+ class="cpub-remote-profile__follow-btn"
105
+ :class="{
106
+ 'cpub-remote-profile__follow-btn--following': searchResult.isFollowing,
107
+ 'cpub-remote-profile__follow-btn--pending': searchResult.isFollowPending,
108
+ }"
109
+ :disabled="searchResult.isFollowPending"
110
+ @click="searchResult.isFollowing ? onUnfollow() : onFollow()"
111
+ >
112
+ {{ searchResult.isFollowing ? 'Following' : searchResult.isFollowPending ? 'Pending' : 'Follow' }}
113
+ </button>
114
+ <button
115
+ v-if="user"
116
+ class="cpub-remote-profile__follow-btn"
117
+ @click="showDmForm = !showDmForm"
118
+ >
119
+ <i class="fa-solid fa-envelope"></i> Message
120
+ </button>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- DM Form -->
125
+ <div v-if="showDmForm" class="cpub-remote-profile__dm-form">
126
+ <textarea
127
+ v-model="dmBody"
128
+ class="cpub-remote-profile__dm-textarea"
129
+ placeholder="Write a message..."
130
+ rows="3"
131
+ ></textarea>
132
+ <div class="cpub-remote-profile__dm-actions">
133
+ <button class="cpub-remote-profile__dm-send" :disabled="dmSending || !dmBody.trim()" @click="sendDm">
134
+ {{ dmSending ? 'Sending...' : 'Send' }}
135
+ </button>
136
+ <button class="cpub-remote-profile__dm-cancel" @click="showDmForm = false">Cancel</button>
137
+ </div>
138
+ </div>
139
+ <div v-if="dmSent" class="cpub-remote-profile__dm-sent">
140
+ <i class="fa-solid fa-check"></i> Message sent via ActivityPub
89
141
  </div>
90
142
 
91
143
  <p v-if="searchResult.summary" class="cpub-remote-profile__bio">
@@ -218,4 +270,55 @@ function stripHtml(html: string): string {
218
270
  flex-direction: column;
219
271
  gap: var(--space-4);
220
272
  }
273
+ .cpub-remote-profile__actions {
274
+ display: flex;
275
+ gap: var(--space-2);
276
+ flex-shrink: 0;
277
+ }
278
+ .cpub-remote-profile__dm-form {
279
+ margin-bottom: var(--space-4);
280
+ padding: var(--space-3);
281
+ border: var(--border-width-default) solid var(--border);
282
+ background: var(--surface-1, var(--surface));
283
+ }
284
+ .cpub-remote-profile__dm-textarea {
285
+ width: 100%;
286
+ padding: var(--space-2);
287
+ border: var(--border-width-default) solid var(--border);
288
+ background: var(--surface-2, var(--surface2));
289
+ color: var(--text);
290
+ font-family: var(--font-sans);
291
+ font-size: var(--font-size-sm);
292
+ resize: vertical;
293
+ margin-bottom: var(--space-2);
294
+ }
295
+ .cpub-remote-profile__dm-actions {
296
+ display: flex;
297
+ gap: var(--space-2);
298
+ }
299
+ .cpub-remote-profile__dm-send {
300
+ padding: var(--space-1) var(--space-3);
301
+ border: var(--border-width-default) solid var(--accent);
302
+ background: var(--accent);
303
+ color: var(--surface-1, var(--surface));
304
+ font-family: var(--font-mono);
305
+ font-size: var(--font-size-sm);
306
+ cursor: pointer;
307
+ }
308
+ .cpub-remote-profile__dm-send:disabled { opacity: 0.5; cursor: default; }
309
+ .cpub-remote-profile__dm-cancel {
310
+ padding: var(--space-1) var(--space-3);
311
+ border: var(--border-width-default) solid var(--border);
312
+ background: transparent;
313
+ color: var(--text-2);
314
+ font-family: var(--font-mono);
315
+ font-size: var(--font-size-sm);
316
+ cursor: pointer;
317
+ }
318
+ .cpub-remote-profile__dm-sent {
319
+ font-size: var(--font-size-sm);
320
+ color: var(--green, #22c55e);
321
+ font-weight: 600;
322
+ margin-bottom: var(--space-4);
323
+ }
221
324
  </style>
@@ -92,6 +92,34 @@ async function deletePost(): Promise<void> {
92
92
  }
93
93
  }
94
94
 
95
+ // Edit
96
+ const editing = ref(false);
97
+ const editContent = ref('');
98
+ const saving = ref(false);
99
+
100
+ function startEdit(): void {
101
+ editContent.value = post.value?.content ?? '';
102
+ editing.value = true;
103
+ }
104
+
105
+ async function saveEdit(): Promise<void> {
106
+ if (!editContent.value.trim()) return;
107
+ saving.value = true;
108
+ try {
109
+ await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}`, {
110
+ method: 'PUT',
111
+ body: { content: editContent.value },
112
+ });
113
+ editing.value = false;
114
+ await refreshPost();
115
+ toast.success('Post updated');
116
+ } catch {
117
+ toast.error('Failed to update post');
118
+ } finally {
119
+ saving.value = false;
120
+ }
121
+ }
122
+
95
123
  function formatDate(d: string | Date): string {
96
124
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
97
125
  }
@@ -121,7 +149,21 @@ useSeoMeta({
121
149
  <span v-if="post.isLocked" class="cpub-post-locked"><i class="fa-solid fa-lock"></i> Locked</span>
122
150
  </div>
123
151
 
124
- <div class="cpub-post-content">{{ post.content }}</div>
152
+ <div v-if="editing" class="cpub-post-edit-form">
153
+ <textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4"></textarea>
154
+ <div class="cpub-post-edit-actions">
155
+ <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving || !editContent.trim()" @click="saveEdit">
156
+ {{ saving ? 'Saving...' : 'Save' }}
157
+ </button>
158
+ <button class="cpub-btn cpub-btn-sm" @click="editing = false">Cancel</button>
159
+ </div>
160
+ </div>
161
+ <div v-else class="cpub-post-content">
162
+ {{ post.content }}
163
+ <div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
164
+ <i class="fa-solid fa-pen"></i> edited
165
+ </div>
166
+ </div>
125
167
 
126
168
  <div class="cpub-post-meta">
127
169
  <div class="cpub-post-author">
@@ -147,6 +189,9 @@ useSeoMeta({
147
189
 
148
190
  <!-- Mod actions -->
149
191
  <div v-if="isMod || isAuthor" class="cpub-post-mod-bar">
192
+ <button v-if="isAuthor && !editing" class="cpub-btn cpub-btn-sm" @click="startEdit">
193
+ <i class="fa-solid fa-pen"></i> Edit
194
+ </button>
150
195
  <button v-if="isMod" class="cpub-btn cpub-btn-sm" @click="togglePin">
151
196
  <i class="fa-solid fa-thumbtack"></i> {{ post.isPinned ? 'Unpin' : 'Pin' }}
152
197
  </button>
@@ -257,6 +302,19 @@ useSeoMeta({
257
302
  font-size: 15px; line-height: 1.7; color: var(--text);
258
303
  margin-bottom: 16px; white-space: pre-wrap;
259
304
  }
305
+ .cpub-post-edited {
306
+ font-size: 10px; font-family: var(--font-mono); color: var(--text-faint);
307
+ margin-top: 4px;
308
+ }
309
+ .cpub-post-edit-form { margin-bottom: 16px; }
310
+ .cpub-post-edit-textarea {
311
+ width: 100%; padding: 10px 12px; background: var(--surface);
312
+ border: var(--border-width-default) solid var(--accent-border);
313
+ color: var(--text); font-size: 14px; font-family: var(--font-sans);
314
+ line-height: 1.6; resize: vertical;
315
+ }
316
+ .cpub-post-edit-textarea:focus { outline: none; border-color: var(--accent); }
317
+ .cpub-post-edit-actions { display: flex; gap: 6px; margin-top: 8px; }
260
318
 
261
319
  .cpub-post-meta {
262
320
  display: flex; align-items: center; justify-content: space-between;
@@ -294,6 +352,9 @@ useSeoMeta({
294
352
  display: flex; gap: 6px;
295
353
  }
296
354
 
355
+ .cpub-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
356
+ .cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
357
+ .cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
297
358
  .cpub-btn-danger { color: var(--red); border-color: var(--red); }
298
359
  .cpub-btn-danger:hover { background: var(--red); color: white; }
299
360
 
@@ -2,7 +2,7 @@
2
2
  const route = useRoute();
3
3
  const conversationId = route.params.conversationId as string;
4
4
 
5
- useSeoMeta({ title: 'Message -- devEco.io' });
5
+ useSeoMeta({ title: () => `Message ${participantLabel.value}` });
6
6
  definePageMeta({ middleware: 'auth' });
7
7
 
8
8
  const { user } = useAuth();
@@ -118,6 +118,7 @@ useSeoMeta({
118
118
  </div>
119
119
  </div>
120
120
  <div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
121
+ <ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
121
122
  <div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
122
123
  <NuxtLink v-for="tag in transformedContent.tags" :key="tag.name" :to="`/tags/${tag.slug || tag.name.toLowerCase().replace(/\s+/g, '-')}`" class="cpub-mirror-tag">{{ tag.name }}</NuxtLink>
123
124
  </div>
@@ -0,0 +1,72 @@
1
+ import { getFederatedHub, listFederatedHubPosts } from '@commonpub/server';
2
+
3
+ function escapeXml(str: string): string {
4
+ return str
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;');
10
+ }
11
+
12
+ function stripHtml(html: string): string {
13
+ return html.replace(/<[^>]*>/g, '').trim();
14
+ }
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ requireFeature('federation');
18
+ requireFeature('federateHubs');
19
+
20
+ const db = useDB();
21
+ const config = useRuntimeConfig();
22
+ const siteUrl = config.public.siteUrl as string;
23
+ const { id } = parseParams(event, { id: 'uuid' });
24
+
25
+ const hub = await getFederatedHub(db, id);
26
+ if (!hub) {
27
+ throw createError({ statusCode: 404, statusMessage: 'Federated hub not found' });
28
+ }
29
+
30
+ const { items } = await listFederatedHubPosts(db, id, { limit: 50 });
31
+
32
+ const lastBuildDate = items.length > 0 && items[0]!.publishedAt
33
+ ? new Date(items[0]!.publishedAt).toUTCString()
34
+ : new Date().toUTCString();
35
+
36
+ const rssItems = items.map((item) => {
37
+ const title = item.sharedContentMeta?.title
38
+ ? `Shared: ${item.sharedContentMeta.title}`
39
+ : stripHtml(item.content).slice(0, 100) || 'Post';
40
+ const link = item.objectUri;
41
+ const pubDate = item.publishedAt ? new Date(item.publishedAt).toUTCString() : new Date(item.receivedAt).toUTCString();
42
+ const authorName = item.author.displayName ?? item.author.preferredUsername ?? 'Unknown';
43
+ const desc = stripHtml(item.content).slice(0, 300);
44
+
45
+ return ` <item>
46
+ <title>${escapeXml(title)}</title>
47
+ <link>${escapeXml(link)}</link>
48
+ <guid isPermaLink="false">${escapeXml(link)}</guid>
49
+ <pubDate>${pubDate}</pubDate>
50
+ <description>${escapeXml(desc)}</description>
51
+ <author>${escapeXml(authorName)}@${escapeXml(item.author.instanceDomain)}</author>
52
+ <category>${escapeXml(item.postType)}</category>
53
+ </item>`;
54
+ });
55
+
56
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
57
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
58
+ <channel>
59
+ <title>${escapeXml(hub.name)} (via ${escapeXml(hub.originDomain)})</title>
60
+ <link>${escapeXml(hub.url ?? `${siteUrl}/federated-hubs/${id}`)}</link>
61
+ <description>${escapeXml(hub.description ?? `Posts from ${hub.name} on ${hub.originDomain}`)}</description>
62
+ <language>en</language>
63
+ <lastBuildDate>${lastBuildDate}</lastBuildDate>
64
+ <atom:link href="${escapeXml(siteUrl)}/api/federated-hubs/${id}/feed.xml" rel="self" type="application/rss+xml"/>
65
+ ${rssItems.join('\n')}
66
+ </channel>
67
+ </rss>`;
68
+
69
+ setResponseHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8');
70
+ setResponseHeader(event, 'Cache-Control', 'public, max-age=600, stale-while-revalidate=300');
71
+ return xml;
72
+ });
@@ -0,0 +1,28 @@
1
+ import { federateDirectMessage, resolveRemoteHandle } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const dmSchema = z.object({
5
+ handle: z.string().min(3),
6
+ body: z.string().min(1).max(10000),
7
+ });
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ requireFeature('federation');
11
+ const user = requireAuth(event);
12
+ const db = useDB();
13
+ const config = useConfig();
14
+ const input = await parseBody(event, dmSchema);
15
+
16
+ const resolved = await resolveRemoteHandle(db, input.handle, config.instance.domain);
17
+ if (!resolved) {
18
+ throw createError({ statusCode: 404, statusMessage: 'Could not resolve remote user' });
19
+ }
20
+
21
+ await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
22
+
23
+ return {
24
+ sent: true,
25
+ recipientActorUri: resolved.actorUri,
26
+ recipientName: resolved.displayName ?? resolved.preferredUsername ?? input.handle,
27
+ };
28
+ });
@@ -1,10 +1,11 @@
1
- import { updateHub, getHubBySlug } from '@commonpub/server';
1
+ import { updateHub, getHubBySlug, federateHubUpdate } from '@commonpub/server';
2
2
  import type { HubDetail } from '@commonpub/server';
3
3
  import { updateHubSchema } from '@commonpub/schema';
4
4
 
5
5
  export default defineEventHandler(async (event): Promise<HubDetail> => {
6
6
  const user = requireAuth(event);
7
7
  const db = useDB();
8
+ const config = useConfig();
8
9
  const { slug } = parseParams(event, { slug: 'string' });
9
10
 
10
11
  const hub = await getHubBySlug(db, slug, user.id);
@@ -18,5 +19,13 @@ export default defineEventHandler(async (event): Promise<HubDetail> => {
18
19
  if (!updated) {
19
20
  throw createError({ statusCode: 403, statusMessage: 'Not authorized to update this hub' });
20
21
  }
22
+
23
+ // Federate the hub metadata update (fire-and-forget)
24
+ if (config.features.federation && config.features.federateHubs) {
25
+ federateHubUpdate(db, hub.id, config.instance.domain).catch((err) => {
26
+ console.error('[hub-federation] Failed to federate hub update:', err);
27
+ });
28
+ }
29
+
21
30
  return updated;
22
31
  });
@@ -1,8 +1,9 @@
1
- import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById } from '@commonpub/server';
1
+ import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById, federateHubPostLike } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
4
  const user = requireAuth(event);
5
5
  const db = useDB();
6
+ const config = useConfig();
6
7
  const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
7
8
 
8
9
  const community = await getHubBySlug(db, slug);
@@ -17,5 +18,13 @@ export default defineEventHandler(async (event) => {
17
18
  return { liked: false };
18
19
  }
19
20
  await likePost(db, user.id, postId);
21
+
22
+ // Federate the like (fire-and-forget)
23
+ if (config.features.federation && config.features.federateHubs) {
24
+ federateHubPostLike(db, user.id, postId, slug, config.instance.domain).catch((err) => {
25
+ console.error('[hub-federation] Failed to federate post like:', err);
26
+ });
27
+ }
28
+
20
29
  return { liked: true };
21
30
  });
@@ -1,11 +1,12 @@
1
- import { createReply } from '@commonpub/server';
1
+ import { createReply, federateHubPostReply } from '@commonpub/server';
2
2
  import type { HubReplyItem } from '@commonpub/server';
3
3
  import { createReplySchema } from '@commonpub/schema';
4
4
 
5
5
  export default defineEventHandler(async (event): Promise<HubReplyItem> => {
6
6
  const user = requireAuth(event);
7
7
  const db = useDB();
8
- const { postId } = parseParams(event, { postId: 'uuid' });
8
+ const config = useConfig();
9
+ const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
9
10
  const body = await readBody(event);
10
11
 
11
12
  const parsed = createReplySchema.safeParse({ ...body, postId });
@@ -17,5 +18,14 @@ export default defineEventHandler(async (event): Promise<HubReplyItem> => {
17
18
  });
18
19
  }
19
20
 
20
- return createReply(db, user.id, parsed.data);
21
+ const reply = await createReply(db, user.id, parsed.data);
22
+
23
+ // Federate the reply as Create(Note) with inReplyTo (fire-and-forget)
24
+ if (config.features.federation && config.features.federateHubs) {
25
+ federateHubPostReply(db, user.id, parsed.data.content, postId, slug, config.instance.domain).catch((err) => {
26
+ console.error('[hub-federation] Failed to federate post reply:', err);
27
+ });
28
+ }
29
+
30
+ return reply;
21
31
  });
@@ -0,0 +1,30 @@
1
+ import { editPost, getHubBySlug, federateHubPostUpdate } from '@commonpub/server';
2
+ import type { HubPostItem } from '@commonpub/server';
3
+ import { editPostSchema } from '@commonpub/schema';
4
+
5
+ export default defineEventHandler(async (event): Promise<HubPostItem> => {
6
+ const user = requireAuth(event);
7
+ const db = useDB();
8
+ const config = useConfig();
9
+ const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
10
+
11
+ const community = await getHubBySlug(db, slug);
12
+ if (!community) {
13
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
14
+ }
15
+
16
+ const input = await parseBody(event, editPostSchema);
17
+ const updated = await editPost(db, postId, user.id, input);
18
+ if (!updated) {
19
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to edit this post' });
20
+ }
21
+
22
+ // Federate the update (fire-and-forget)
23
+ if (config.features.federation && config.features.federateHubs) {
24
+ federateHubPostUpdate(db, postId, community.id, config.instance.domain).catch((err) => {
25
+ console.error('[hub-federation] Failed to federate post update:', err);
26
+ });
27
+ }
28
+
29
+ return updated;
30
+ });
@@ -1,10 +1,11 @@
1
- import { shareContent, getHubBySlug, federateHubShare, getContentSlugById, buildContentUri } from '@commonpub/server';
1
+ import { shareContent, getHubBySlug, getFederatedContent, createPost, federateHubShare, getContentSlugById, buildContentUri } from '@commonpub/server';
2
2
  import type { HubPostItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
5
5
  const shareContentSchema = z.object({
6
- contentId: z.string().uuid(),
7
- });
6
+ contentId: z.string().uuid().optional(),
7
+ federatedContentId: z.string().uuid().optional(),
8
+ }).refine((d) => d.contentId || d.federatedContentId, { message: 'Either contentId or federatedContentId is required' });
8
9
 
9
10
  export default defineEventHandler(async (event): Promise<HubPostItem> => {
10
11
  const user = requireAuth(event);
@@ -18,19 +19,53 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
18
19
  throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
19
20
  }
20
21
 
21
- const post = await shareContent(db, user.id, hub.id, input.contentId);
22
- if (!post) {
23
- throw createError({ statusCode: 400, statusMessage: 'Cannot share. You must be a hub member and the content must exist.' });
22
+ // Share local content
23
+ if (input.contentId) {
24
+ const post = await shareContent(db, user.id, hub.id, input.contentId);
25
+ if (!post) {
26
+ throw createError({ statusCode: 400, statusMessage: 'Cannot share. You must be a hub member and the content must exist.' });
27
+ }
28
+
29
+ // Federate the shared content as Announce from the hub Group actor
30
+ if (config.features.federation && config.features.federateHubs) {
31
+ getContentSlugById(db, input.contentId).then((contentSlug) => {
32
+ if (contentSlug) {
33
+ const contentUri = buildContentUri(config.instance.domain, contentSlug);
34
+ return federateHubShare(db, contentUri, hub.id, config.instance.domain);
35
+ }
36
+ }).catch((err) => {
37
+ console.error('[hub-federation] Failed to federate share:', err);
38
+ });
39
+ }
40
+
41
+ return post;
24
42
  }
25
43
 
26
- // Federate the shared content as Announce from the hub Group actor
44
+ // Share federated content
45
+ const fedContent = await getFederatedContent(db, input.federatedContentId!);
46
+ if (!fedContent) {
47
+ throw createError({ statusCode: 404, statusMessage: 'Federated content not found' });
48
+ }
49
+
50
+ const sharePayload = JSON.stringify({
51
+ federatedContentId: fedContent.id,
52
+ title: fedContent.title,
53
+ type: fedContent.cpubType ?? fedContent.apType ?? 'article',
54
+ coverImageUrl: fedContent.coverImageUrl ?? null,
55
+ description: fedContent.summary ?? null,
56
+ originUrl: fedContent.url ?? fedContent.objectUri,
57
+ originDomain: fedContent.originDomain,
58
+ });
59
+
60
+ const post = await createPost(db, user.id, {
61
+ hubId: hub.id,
62
+ type: 'share',
63
+ content: sharePayload,
64
+ });
65
+
66
+ // Federate the share using the object URI of the federated content
27
67
  if (config.features.federation && config.features.federateHubs) {
28
- getContentSlugById(db, input.contentId).then((contentSlug) => {
29
- if (contentSlug) {
30
- const contentUri = buildContentUri(config.instance.domain, contentSlug);
31
- return federateHubShare(db, contentUri, hub.id, config.instance.domain);
32
- }
33
- }).catch((err) => {
68
+ federateHubShare(db, fedContent.objectUri, hub.id, config.instance.domain).catch((err) => {
34
69
  console.error('[hub-federation] Failed to federate share:', err);
35
70
  });
36
71
  }
@@ -1,11 +1,21 @@
1
- import { createHub } from '@commonpub/server';
1
+ import { createHub, federateHubActor } from '@commonpub/server';
2
2
  import type { HubDetail } from '@commonpub/server';
3
3
  import { createHubSchema } from '@commonpub/schema';
4
4
 
5
5
  export default defineEventHandler(async (event): Promise<HubDetail> => {
6
6
  const user = requireAuth(event);
7
7
  const db = useDB();
8
+ const config = useConfig();
8
9
  const input = await parseBody(event, createHubSchema);
9
10
 
10
- return createHub(db, user.id, input);
11
+ const hub = await createHub(db, user.id, input);
12
+
13
+ // Federate new hub as Group actor Announce (fire-and-forget)
14
+ if (config.features.federation && config.features.federateHubs) {
15
+ federateHubActor(db, hub.id, config.instance.domain).catch((err) => {
16
+ console.error('[hub-federation] Failed to federate hub actor:', err);
17
+ });
18
+ }
19
+
20
+ return hub;
11
21
  });