@commonpub/layer 0.3.12 → 0.3.14

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,115 @@
1
+ <script setup lang="ts">
2
+ const { attachments } = 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 isSafeUrl(url: string): boolean {
14
+ try {
15
+ const parsed = new URL(url);
16
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function fileName(att: { url: string; name?: string }): string {
23
+ if (att.name) return att.name;
24
+ try {
25
+ return new URL(att.url).pathname.split('/').pop() ?? 'attachment';
26
+ } catch {
27
+ return 'attachment';
28
+ }
29
+ }
30
+
31
+ const safeAttachments = computed(() =>
32
+ attachments.filter((att) => att.url && att.type && isSafeUrl(att.url)),
33
+ );
34
+ </script>
35
+
36
+ <template>
37
+ <div v-if="safeAttachments.length > 0" class="cpub-attachments">
38
+ <div class="cpub-attachments-label">Attachments</div>
39
+ <div class="cpub-attachments-list">
40
+ <a
41
+ v-for="(att, i) in safeAttachments"
42
+ :key="i"
43
+ :href="att.url"
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ class="cpub-attachment-item"
47
+ >
48
+ <img v-if="att.type === 'Image'" :src="att.url" :alt="att.name ?? ''" class="cpub-attachment-thumb" loading="lazy" />
49
+ <div v-else class="cpub-attachment-icon"><i :class="iconForType(att.type)"></i></div>
50
+ <span class="cpub-attachment-name">{{ fileName(att) }}</span>
51
+ </a>
52
+ </div>
53
+ </div>
54
+ </template>
55
+
56
+ <style scoped>
57
+ .cpub-attachments {
58
+ margin: 28px 0;
59
+ padding-top: 16px;
60
+ border-top: 1px solid var(--border);
61
+ }
62
+ .cpub-attachments-label {
63
+ font-size: 10px;
64
+ font-family: var(--font-mono);
65
+ color: var(--text-faint);
66
+ letter-spacing: 0.1em;
67
+ text-transform: uppercase;
68
+ margin-bottom: 10px;
69
+ }
70
+ .cpub-attachments-list {
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ gap: 8px;
74
+ }
75
+ .cpub-attachment-item {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 8px;
79
+ padding: 8px 12px;
80
+ border: 1px solid var(--border);
81
+ background: var(--surface);
82
+ text-decoration: none;
83
+ color: var(--text-dim);
84
+ font-size: 12px;
85
+ transition: background var(--transition-fast);
86
+ }
87
+ .cpub-attachment-item:hover {
88
+ background: var(--surface2);
89
+ color: var(--accent);
90
+ }
91
+ .cpub-attachment-thumb {
92
+ width: 40px;
93
+ height: 40px;
94
+ object-fit: cover;
95
+ border: 1px solid var(--border2);
96
+ }
97
+ .cpub-attachment-icon {
98
+ width: 32px;
99
+ height: 32px;
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ background: var(--surface2);
104
+ border: 1px solid var(--border2);
105
+ color: var(--text-faint);
106
+ font-size: 14px;
107
+ }
108
+ .cpub-attachment-name {
109
+ font-family: var(--font-mono);
110
+ max-width: 200px;
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ white-space: nowrap;
114
+ }
115
+ </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.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -44,15 +44,15 @@
44
44
  "vue": "^3.4.0",
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
- "@commonpub/auth": "0.5.0",
48
47
  "@commonpub/docs": "0.5.2",
49
- "@commonpub/config": "0.7.0",
50
- "@commonpub/protocol": "0.9.4",
51
48
  "@commonpub/editor": "0.5.0",
52
49
  "@commonpub/learning": "0.5.0",
53
- "@commonpub/schema": "0.8.10",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/server": "2.10.0"
50
+ "@commonpub/protocol": "0.9.4",
51
+ "@commonpub/server": "2.11.1",
52
+ "@commonpub/schema": "0.8.11",
53
+ "@commonpub/auth": "0.5.0",
54
+ "@commonpub/config": "0.7.0",
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -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>
@@ -62,20 +62,22 @@ async function toggleLike(): Promise<void> {
62
62
 
63
63
  // Mod actions
64
64
  async function togglePin(): Promise<void> {
65
+ const wasPinned = post.value?.isPinned;
65
66
  try {
66
67
  await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/pin`, { method: 'POST' });
67
68
  await refreshPost();
68
- toast.success(post.value?.isPinned ? 'Post unpinned' : 'Post pinned');
69
+ toast.success(wasPinned ? 'Post unpinned' : 'Post pinned');
69
70
  } catch {
70
71
  toast.error('Failed to toggle pin');
71
72
  }
72
73
  }
73
74
 
74
75
  async function toggleLock(): Promise<void> {
76
+ const wasLocked = post.value?.isLocked;
75
77
  try {
76
78
  await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/lock`, { method: 'POST' });
77
79
  await refreshPost();
78
- toast.success(post.value?.isLocked ? 'Post unlocked' : 'Post locked');
80
+ toast.success(wasLocked ? 'Post unlocked' : 'Post locked');
79
81
  } catch {
80
82
  toast.error('Failed to toggle lock');
81
83
  }
@@ -92,6 +94,34 @@ async function deletePost(): Promise<void> {
92
94
  }
93
95
  }
94
96
 
97
+ // Edit
98
+ const editing = ref(false);
99
+ const editContent = ref('');
100
+ const saving = ref(false);
101
+
102
+ function startEdit(): void {
103
+ editContent.value = post.value?.content ?? '';
104
+ editing.value = true;
105
+ }
106
+
107
+ async function saveEdit(): Promise<void> {
108
+ if (!editContent.value.trim()) return;
109
+ saving.value = true;
110
+ try {
111
+ await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}`, {
112
+ method: 'PUT',
113
+ body: { content: editContent.value },
114
+ });
115
+ editing.value = false;
116
+ await refreshPost();
117
+ toast.success('Post updated');
118
+ } catch {
119
+ toast.error('Failed to update post');
120
+ } finally {
121
+ saving.value = false;
122
+ }
123
+ }
124
+
95
125
  function formatDate(d: string | Date): string {
96
126
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
97
127
  }
@@ -121,7 +151,21 @@ useSeoMeta({
121
151
  <span v-if="post.isLocked" class="cpub-post-locked"><i class="fa-solid fa-lock"></i> Locked</span>
122
152
  </div>
123
153
 
124
- <div class="cpub-post-content">{{ post.content }}</div>
154
+ <div v-if="editing" class="cpub-post-edit-form">
155
+ <textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4"></textarea>
156
+ <div class="cpub-post-edit-actions">
157
+ <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving || !editContent.trim()" @click="saveEdit">
158
+ {{ saving ? 'Saving...' : 'Save' }}
159
+ </button>
160
+ <button class="cpub-btn cpub-btn-sm" @click="editing = false">Cancel</button>
161
+ </div>
162
+ </div>
163
+ <div v-else class="cpub-post-content">
164
+ {{ post.content }}
165
+ <div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
166
+ <i class="fa-solid fa-pen"></i> edited
167
+ </div>
168
+ </div>
125
169
 
126
170
  <div class="cpub-post-meta">
127
171
  <div class="cpub-post-author">
@@ -147,6 +191,9 @@ useSeoMeta({
147
191
 
148
192
  <!-- Mod actions -->
149
193
  <div v-if="isMod || isAuthor" class="cpub-post-mod-bar">
194
+ <button v-if="isAuthor && !editing" class="cpub-btn cpub-btn-sm" @click="startEdit">
195
+ <i class="fa-solid fa-pen"></i> Edit
196
+ </button>
150
197
  <button v-if="isMod" class="cpub-btn cpub-btn-sm" @click="togglePin">
151
198
  <i class="fa-solid fa-thumbtack"></i> {{ post.isPinned ? 'Unpin' : 'Pin' }}
152
199
  </button>
@@ -257,6 +304,19 @@ useSeoMeta({
257
304
  font-size: 15px; line-height: 1.7; color: var(--text);
258
305
  margin-bottom: 16px; white-space: pre-wrap;
259
306
  }
307
+ .cpub-post-edited {
308
+ font-size: 10px; font-family: var(--font-mono); color: var(--text-faint);
309
+ margin-top: 4px;
310
+ }
311
+ .cpub-post-edit-form { margin-bottom: 16px; }
312
+ .cpub-post-edit-textarea {
313
+ width: 100%; padding: 10px 12px; background: var(--surface);
314
+ border: var(--border-width-default) solid var(--accent-border);
315
+ color: var(--text); font-size: 14px; font-family: var(--font-sans);
316
+ line-height: 1.6; resize: vertical;
317
+ }
318
+ .cpub-post-edit-textarea:focus { outline: none; border-color: var(--accent); }
319
+ .cpub-post-edit-actions { display: flex; gap: 6px; margin-top: 8px; }
260
320
 
261
321
  .cpub-post-meta {
262
322
  display: flex; align-items: center; justify-content: space-between;
@@ -294,6 +354,9 @@ useSeoMeta({
294
354
  display: flex; gap: 6px;
295
355
  }
296
356
 
357
+ .cpub-btn-primary { background: var(--accent); color: var(--accent-text, #fff); border-color: var(--accent); }
358
+ .cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
359
+ .cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
297
360
  .cpub-btn-danger { color: var(--red); border-color: var(--red); }
298
361
  .cpub-btn-danger:hover { background: var(--red); color: white; }
299
362
 
@@ -2,7 +2,6 @@
2
2
  const route = useRoute();
3
3
  const conversationId = route.params.conversationId as string;
4
4
 
5
- useSeoMeta({ title: 'Message -- devEco.io' });
6
5
  definePageMeta({ middleware: 'auth' });
7
6
 
8
7
  const { user } = useAuth();
@@ -57,17 +56,18 @@ const participantLabel = computed(() => {
57
56
  return others.length > 0 ? others.join(', ') : 'Conversation';
58
57
  });
59
58
 
59
+ useSeoMeta({ title: () => `Message — ${participantLabel.value}` });
60
+
60
61
  async function handleSend(text: string): Promise<void> {
61
62
  await $fetch(`/api/messages/${conversationId}` as string, {
62
63
  method: 'POST',
63
64
  body: { body: text },
64
65
  });
65
66
  // SSE will pick up the new message, but also do an immediate refresh for responsiveness
66
- refresh().then((result: any) => {
67
- if (result?.data?.value) {
68
- messages.value = result.data.value;
69
- }
70
- });
67
+ await refresh();
68
+ if (initialMessages.value) {
69
+ messages.value = [...initialMessages.value];
70
+ }
71
71
  }
72
72
  </script>
73
73
 
@@ -117,7 +117,9 @@ useSeoMeta({
117
117
  <p v-if="transformedContent.author.bio" class="cpub-mirror-bio">{{ stripHtml(transformedContent.author.bio) }}</p>
118
118
  </div>
119
119
  </div>
120
+ <!-- Content is sanitized on ingest (inboxHandlers.ts → sanitizeHtml). Safe for v-html. -->
120
121
  <div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
122
+ <ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
121
123
  <div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
122
124
  <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
125
  </div>
@@ -145,7 +147,7 @@ useSeoMeta({
145
147
  display: flex; align-items: center; gap: 4px; font-size: 11px;
146
148
  }
147
149
  .cpub-fed-banner-follow {
148
- margin-left: auto; background: var(--accent); color: #fff; border: none;
150
+ margin-left: auto; background: var(--accent); color: var(--accent-text, #fff); border: none;
149
151
  font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
150
152
  display: flex; align-items: center; gap: 4px; white-space: nowrap;
151
153
  }
@@ -0,0 +1,32 @@
1
+ import { federateDirectMessage, resolveRemoteHandle } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const dmSchema = z.object({
5
+ handle: z.string().min(3).regex(/^@?[\w.-]+@[\w.-]+\.\w+$/, 'Invalid federation handle'),
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
+ try {
22
+ await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
23
+ } catch {
24
+ throw createError({ statusCode: 502, statusMessage: 'Failed to deliver message to remote server' });
25
+ }
26
+
27
+ return {
28
+ sent: true,
29
+ recipientActorUri: resolved.actorUri,
30
+ recipientName: resolved.displayName ?? resolved.preferredUsername ?? input.handle,
31
+ };
32
+ });
@@ -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, checkBan } 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);
@@ -11,11 +12,23 @@ export default defineEventHandler(async (event) => {
11
12
  const post = await getPostById(db, postId);
12
13
  if (!post || post.hubId !== community.id) throw createError({ statusCode: 404, statusMessage: 'Post not found' });
13
14
 
15
+ // Banned users cannot like posts
16
+ const ban = await checkBan(db, community.id, user.id);
17
+ if (ban) throw createError({ statusCode: 403, statusMessage: 'You are banned from this hub' });
18
+
14
19
  const alreadyLiked = await hasLikedPost(db, user.id, postId);
15
20
  if (alreadyLiked) {
16
21
  await unlikePost(db, user.id, postId);
17
22
  return { liked: false };
18
23
  }
19
24
  await likePost(db, user.id, postId);
25
+
26
+ // Federate the like (fire-and-forget)
27
+ if (config.features.federation && config.features.federateHubs) {
28
+ federateHubPostLike(db, user.id, postId, slug, config.instance.domain).catch((err) => {
29
+ console.error('[hub-federation] Failed to federate post like:', err);
30
+ });
31
+ }
32
+
20
33
  return { liked: true };
21
34
  });
@@ -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, community.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
  });