@commonpub/layer 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/components/FeedItem.vue +8 -1
- package/components/__tests__/FederatedContentCard.test.ts +340 -0
- package/components/hub/HubDiscussions.vue +10 -3
- package/components/hub/HubFeed.vue +9 -4
- package/composables/__tests__/useMirrorContent.test.ts +208 -0
- package/package.json +14 -7
- package/pages/auth/oauth/authorize.vue +170 -34
- package/pages/federated-hubs/[id]/index.vue +6 -3
- package/pages/federated-hubs/[id]/posts/[postId].vue +9 -3
- package/pages/hubs/[slug]/posts/[postId].vue +1 -1
- package/pages/search.vue +1 -1
- package/pages/settings/appearance.vue +5 -4
- package/server/plugins/federation-hub-sync.ts +14 -0
- package/server/routes/content/[slug].ts +71 -0
- package/server/routes/hubs/[slug]/posts/[postId].ts +31 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ API routes for all CommonPub features, auth middleware, federation endpoints, an
|
|
|
65
65
|
|
|
66
66
|
### Theme
|
|
67
67
|
|
|
68
|
-
CSS custom properties with
|
|
68
|
+
CSS custom properties with 3 built-in themes (base, dark, generics). Consumer apps override with their own `theme.css`.
|
|
69
69
|
|
|
70
70
|
## Customization
|
|
71
71
|
|
package/components/FeedItem.vue
CHANGED
|
@@ -17,6 +17,13 @@ const props = defineProps<{
|
|
|
17
17
|
|
|
18
18
|
const emit = defineEmits<{ vote: [] }>();
|
|
19
19
|
|
|
20
|
+
/** Strip HTML tags for safe plain-text preview */
|
|
21
|
+
function stripHtml(html: string): string {
|
|
22
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const previewText = computed(() => stripHtml(props.body));
|
|
26
|
+
|
|
20
27
|
const typeBadgeClass = computed((): string => {
|
|
21
28
|
const map: Record<string, string> = {
|
|
22
29
|
discussion: 'cpub-feed-badge-accent',
|
|
@@ -55,7 +62,7 @@ const formattedDate = computed((): string => {
|
|
|
55
62
|
|
|
56
63
|
<h3 class="cpub-feed-item-title">{{ title }}</h3>
|
|
57
64
|
|
|
58
|
-
<p class="cpub-feed-item-preview">{{
|
|
65
|
+
<p class="cpub-feed-item-preview">{{ previewText }}</p>
|
|
59
66
|
|
|
60
67
|
<div class="cpub-feed-item-meta">
|
|
61
68
|
<div class="cpub-feed-item-author">
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for FederatedContentCard.
|
|
3
|
+
*
|
|
4
|
+
* Tests rendering of federated content from CommonPub and non-CommonPub sources,
|
|
5
|
+
* computed properties (typeLabel, actorHandle, timeAgo), event emission, and
|
|
6
|
+
* conditional rendering (avatar, cover image, tags, title link).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { render, screen, fireEvent } from '@testing-library/vue';
|
|
10
|
+
import { defineComponent, h } from 'vue';
|
|
11
|
+
import FederatedContentCard from '../FederatedContentCard.vue';
|
|
12
|
+
|
|
13
|
+
// Stub NuxtLink as a plain <a> tag
|
|
14
|
+
const NuxtLink = defineComponent({
|
|
15
|
+
name: 'NuxtLink',
|
|
16
|
+
props: { to: String },
|
|
17
|
+
setup(props, { slots }) {
|
|
18
|
+
return () => h('a', { href: props.to }, slots.default?.());
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const stubs = { NuxtLink };
|
|
23
|
+
|
|
24
|
+
function makeContent(overrides: Record<string, unknown> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
id: 'fed-1',
|
|
27
|
+
objectUri: 'https://remote.example.com/content/test',
|
|
28
|
+
apType: 'Article',
|
|
29
|
+
title: 'LED Cube Build',
|
|
30
|
+
content: '<p>Build a 4x4x4 LED cube</p>',
|
|
31
|
+
summary: '<p>A <strong>complete</strong> LED cube tutorial</p>',
|
|
32
|
+
url: 'https://remote.example.com/project/led-cube',
|
|
33
|
+
coverImageUrl: null,
|
|
34
|
+
tags: [],
|
|
35
|
+
attachments: [],
|
|
36
|
+
inReplyTo: null,
|
|
37
|
+
cpubType: 'project',
|
|
38
|
+
cpubMetadata: null,
|
|
39
|
+
cpubBlocks: null,
|
|
40
|
+
localLikeCount: 5,
|
|
41
|
+
localCommentCount: 2,
|
|
42
|
+
localViewCount: 100,
|
|
43
|
+
publishedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
|
44
|
+
receivedAt: new Date().toISOString(),
|
|
45
|
+
originDomain: 'remote.example.com',
|
|
46
|
+
actor: {
|
|
47
|
+
actorUri: 'https://remote.example.com/users/alice',
|
|
48
|
+
preferredUsername: 'alice',
|
|
49
|
+
displayName: 'Alice Builder',
|
|
50
|
+
avatarUrl: 'https://remote.example.com/avatars/alice.png',
|
|
51
|
+
instanceDomain: 'remote.example.com',
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('FederatedContentCard', () => {
|
|
58
|
+
// --- Basic rendering ---
|
|
59
|
+
|
|
60
|
+
it('renders title', () => {
|
|
61
|
+
render(FederatedContentCard, {
|
|
62
|
+
props: { content: makeContent() },
|
|
63
|
+
global: { stubs },
|
|
64
|
+
});
|
|
65
|
+
expect(screen.getByText('LED Cube Build')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders actor name and handle', () => {
|
|
69
|
+
render(FederatedContentCard, {
|
|
70
|
+
props: { content: makeContent() },
|
|
71
|
+
global: { stubs },
|
|
72
|
+
});
|
|
73
|
+
expect(screen.getByText('Alice Builder')).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText('@alice@remote.example.com')).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders origin domain badge', () => {
|
|
78
|
+
render(FederatedContentCard, {
|
|
79
|
+
props: { content: makeContent() },
|
|
80
|
+
global: { stubs },
|
|
81
|
+
});
|
|
82
|
+
expect(screen.getByText('remote.example.com')).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('strips HTML from summary', () => {
|
|
86
|
+
render(FederatedContentCard, {
|
|
87
|
+
props: { content: makeContent() },
|
|
88
|
+
global: { stubs },
|
|
89
|
+
});
|
|
90
|
+
const summary = screen.getByText('A complete LED cube tutorial');
|
|
91
|
+
expect(summary).toBeInTheDocument();
|
|
92
|
+
// Should NOT contain HTML tags
|
|
93
|
+
expect(summary.innerHTML).not.toContain('<strong>');
|
|
94
|
+
expect(summary.innerHTML).not.toContain('<p>');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- Type label computed ---
|
|
98
|
+
|
|
99
|
+
it('shows cpubType as type badge when present', () => {
|
|
100
|
+
render(FederatedContentCard, {
|
|
101
|
+
props: { content: makeContent({ cpubType: 'project' }) },
|
|
102
|
+
global: { stubs },
|
|
103
|
+
});
|
|
104
|
+
expect(screen.getByText('project')).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('shows "article" for AP Article without cpubType', () => {
|
|
108
|
+
render(FederatedContentCard, {
|
|
109
|
+
props: { content: makeContent({ cpubType: null, apType: 'Article' }) },
|
|
110
|
+
global: { stubs },
|
|
111
|
+
});
|
|
112
|
+
expect(screen.getByText('article')).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('shows "post" for AP Note without cpubType', () => {
|
|
116
|
+
render(FederatedContentCard, {
|
|
117
|
+
props: { content: makeContent({ cpubType: null, apType: 'Note' }) },
|
|
118
|
+
global: { stubs },
|
|
119
|
+
});
|
|
120
|
+
expect(screen.getByText('post')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Avatar rendering ---
|
|
124
|
+
|
|
125
|
+
it('renders avatar image when actor has avatarUrl', () => {
|
|
126
|
+
render(FederatedContentCard, {
|
|
127
|
+
props: { content: makeContent() },
|
|
128
|
+
global: { stubs },
|
|
129
|
+
});
|
|
130
|
+
const img = screen.getByAltText('Alice Builder avatar');
|
|
131
|
+
expect(img).toBeInTheDocument();
|
|
132
|
+
expect(img).toHaveAttribute('src', 'https://remote.example.com/avatars/alice.png');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('renders placeholder when actor has no avatarUrl', () => {
|
|
136
|
+
const { container } = render(FederatedContentCard, {
|
|
137
|
+
props: {
|
|
138
|
+
content: makeContent({
|
|
139
|
+
actor: {
|
|
140
|
+
actorUri: 'https://remote.example.com/users/bob',
|
|
141
|
+
preferredUsername: 'bob',
|
|
142
|
+
displayName: 'Bob',
|
|
143
|
+
avatarUrl: null,
|
|
144
|
+
instanceDomain: 'remote.example.com',
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
global: { stubs },
|
|
149
|
+
});
|
|
150
|
+
const placeholder = container.querySelector('.cpub-fed-card__avatar--placeholder');
|
|
151
|
+
expect(placeholder).toBeInTheDocument();
|
|
152
|
+
expect(placeholder?.textContent).toBe('B');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// --- Cover image ---
|
|
156
|
+
|
|
157
|
+
it('renders cover image through proxy when coverImageUrl present', () => {
|
|
158
|
+
const { container } = render(FederatedContentCard, {
|
|
159
|
+
props: {
|
|
160
|
+
content: makeContent({
|
|
161
|
+
coverImageUrl: 'https://remote.example.com/img/cover.jpg',
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
global: { stubs },
|
|
165
|
+
});
|
|
166
|
+
const cover = container.querySelector('.cpub-fed-card__cover img');
|
|
167
|
+
expect(cover).toBeInTheDocument();
|
|
168
|
+
expect(cover?.getAttribute('src')).toContain('/api/image-proxy');
|
|
169
|
+
expect(cover?.getAttribute('src')).toContain(encodeURIComponent('https://remote.example.com/img/cover.jpg'));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not render cover image when coverImageUrl is null', () => {
|
|
173
|
+
const { container } = render(FederatedContentCard, {
|
|
174
|
+
props: { content: makeContent({ coverImageUrl: null }) },
|
|
175
|
+
global: { stubs },
|
|
176
|
+
});
|
|
177
|
+
expect(container.querySelector('.cpub-fed-card__cover')).not.toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- Tags ---
|
|
181
|
+
|
|
182
|
+
it('renders tags when present', () => {
|
|
183
|
+
render(FederatedContentCard, {
|
|
184
|
+
props: {
|
|
185
|
+
content: makeContent({
|
|
186
|
+
tags: [
|
|
187
|
+
{ type: 'Hashtag', name: '#electronics' },
|
|
188
|
+
{ type: 'Hashtag', name: '#led' },
|
|
189
|
+
],
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
global: { stubs },
|
|
193
|
+
});
|
|
194
|
+
expect(screen.getByText('#electronics')).toBeInTheDocument();
|
|
195
|
+
expect(screen.getByText('#led')).toBeInTheDocument();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('limits tags to 5', () => {
|
|
199
|
+
const tags = Array.from({ length: 8 }, (_, i) => ({
|
|
200
|
+
type: 'Hashtag',
|
|
201
|
+
name: `#tag${i}`,
|
|
202
|
+
}));
|
|
203
|
+
const { container } = render(FederatedContentCard, {
|
|
204
|
+
props: { content: makeContent({ tags }) },
|
|
205
|
+
global: { stubs },
|
|
206
|
+
});
|
|
207
|
+
const tagElements = container.querySelectorAll('.cpub-fed-card__tag');
|
|
208
|
+
expect(tagElements.length).toBe(5);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('hides tags section when empty', () => {
|
|
212
|
+
const { container } = render(FederatedContentCard, {
|
|
213
|
+
props: { content: makeContent({ tags: [] }) },
|
|
214
|
+
global: { stubs },
|
|
215
|
+
});
|
|
216
|
+
expect(container.querySelector('.cpub-fed-card__tags')).not.toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// --- Like count ---
|
|
220
|
+
|
|
221
|
+
it('shows like count when > 0', () => {
|
|
222
|
+
render(FederatedContentCard, {
|
|
223
|
+
props: { content: makeContent({ localLikeCount: 5 }) },
|
|
224
|
+
global: { stubs },
|
|
225
|
+
});
|
|
226
|
+
expect(screen.getByLabelText('Like this project')).toHaveTextContent('5 Like');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('hides like count when 0', () => {
|
|
230
|
+
render(FederatedContentCard, {
|
|
231
|
+
props: { content: makeContent({ localLikeCount: 0 }) },
|
|
232
|
+
global: { stubs },
|
|
233
|
+
});
|
|
234
|
+
expect(screen.getByLabelText('Like this project')).toHaveTextContent('Like');
|
|
235
|
+
expect(screen.getByLabelText('Like this project').textContent?.trim()).toBe('Like');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// --- Events ---
|
|
239
|
+
|
|
240
|
+
it('emits like event with content id', async () => {
|
|
241
|
+
const { emitted } = render(FederatedContentCard, {
|
|
242
|
+
props: { content: makeContent() },
|
|
243
|
+
global: { stubs },
|
|
244
|
+
});
|
|
245
|
+
await fireEvent.click(screen.getByLabelText('Like this project'));
|
|
246
|
+
expect(emitted().like).toBeTruthy();
|
|
247
|
+
expect(emitted().like[0]).toEqual(['fed-1']);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('emits boost event with content id', async () => {
|
|
251
|
+
const { emitted } = render(FederatedContentCard, {
|
|
252
|
+
props: { content: makeContent() },
|
|
253
|
+
global: { stubs },
|
|
254
|
+
});
|
|
255
|
+
await fireEvent.click(screen.getByLabelText('Boost this project'));
|
|
256
|
+
expect(emitted().boost).toBeTruthy();
|
|
257
|
+
expect(emitted().boost[0]).toEqual(['fed-1']);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// --- Title link ---
|
|
261
|
+
|
|
262
|
+
it('renders title as link when url is present', () => {
|
|
263
|
+
render(FederatedContentCard, {
|
|
264
|
+
props: { content: makeContent() },
|
|
265
|
+
global: { stubs },
|
|
266
|
+
});
|
|
267
|
+
const link = screen.getByText('LED Cube Build').closest('a');
|
|
268
|
+
expect(link).toHaveAttribute('href', 'https://remote.example.com/project/led-cube');
|
|
269
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('renders title as plain text when url is null', () => {
|
|
273
|
+
render(FederatedContentCard, {
|
|
274
|
+
props: { content: makeContent({ url: null }) },
|
|
275
|
+
global: { stubs },
|
|
276
|
+
});
|
|
277
|
+
const title = screen.getByText('LED Cube Build');
|
|
278
|
+
expect(title.tagName).toBe('SPAN');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// --- View Original link ---
|
|
282
|
+
|
|
283
|
+
it('shows View Original link when url present', () => {
|
|
284
|
+
render(FederatedContentCard, {
|
|
285
|
+
props: { content: makeContent() },
|
|
286
|
+
global: { stubs },
|
|
287
|
+
});
|
|
288
|
+
const link = screen.getByText('View Original');
|
|
289
|
+
expect(link).toHaveAttribute('href', 'https://remote.example.com/project/led-cube');
|
|
290
|
+
expect(link).toHaveAttribute('rel', 'noopener');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('hides View Original when no url', () => {
|
|
294
|
+
render(FederatedContentCard, {
|
|
295
|
+
props: { content: makeContent({ url: null }) },
|
|
296
|
+
global: { stubs },
|
|
297
|
+
});
|
|
298
|
+
expect(screen.queryByText('View Original')).not.toBeInTheDocument();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// --- Time ago ---
|
|
302
|
+
|
|
303
|
+
it('shows relative time for recent content', () => {
|
|
304
|
+
const { container } = render(FederatedContentCard, {
|
|
305
|
+
props: {
|
|
306
|
+
content: makeContent({
|
|
307
|
+
publishedAt: new Date(Date.now() - 30 * 60000).toISOString(),
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
global: { stubs },
|
|
311
|
+
});
|
|
312
|
+
const time = container.querySelector('.cpub-fed-card__time');
|
|
313
|
+
expect(time?.textContent).toBe('30m');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('shows hours for content from today', () => {
|
|
317
|
+
const { container } = render(FederatedContentCard, {
|
|
318
|
+
props: {
|
|
319
|
+
content: makeContent({
|
|
320
|
+
publishedAt: new Date(Date.now() - 5 * 3600000).toISOString(),
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
global: { stubs },
|
|
324
|
+
});
|
|
325
|
+
const time = container.querySelector('.cpub-fed-card__time');
|
|
326
|
+
expect(time?.textContent).toBe('5h');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// --- Fallback values ---
|
|
330
|
+
|
|
331
|
+
it('shows Unknown when actor is null', () => {
|
|
332
|
+
render(FederatedContentCard, {
|
|
333
|
+
props: { content: makeContent({ actor: null }) },
|
|
334
|
+
global: { stubs },
|
|
335
|
+
});
|
|
336
|
+
// Both actorName and actorHandle render "Unknown" fallback
|
|
337
|
+
const unknowns = screen.getAllByText('Unknown');
|
|
338
|
+
expect(unknowns.length).toBeGreaterThanOrEqual(1);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -5,8 +5,15 @@ const props = defineProps<{
|
|
|
5
5
|
posts: HubPostViewModel[]
|
|
6
6
|
}>();
|
|
7
7
|
|
|
8
|
+
function stripHtml(html: string): string {
|
|
9
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
8
12
|
const discussionPosts = computed(() => {
|
|
9
|
-
return props.posts.filter((p) =>
|
|
13
|
+
return props.posts.filter((p) =>
|
|
14
|
+
(p.type === 'text' || p.type === 'link' || p.type === 'discussion' || p.type === 'question')
|
|
15
|
+
&& !p.sharedContent,
|
|
16
|
+
);
|
|
10
17
|
});
|
|
11
18
|
</script>
|
|
12
19
|
|
|
@@ -18,7 +25,7 @@ const discussionPosts = computed(() => {
|
|
|
18
25
|
<template v-for="post in discussionPosts" :key="post.id">
|
|
19
26
|
<NuxtLink v-if="post.linkTo" :to="post.linkTo" class="cpub-feed-link">
|
|
20
27
|
<DiscussionItem
|
|
21
|
-
:title="post.content
|
|
28
|
+
:title="stripHtml(post.content || '').slice(0, 80) || 'Untitled'"
|
|
22
29
|
:author="post.author.name"
|
|
23
30
|
:reply-count="post.replyCount"
|
|
24
31
|
:vote-count="post.likeCount"
|
|
@@ -26,7 +33,7 @@ const discussionPosts = computed(() => {
|
|
|
26
33
|
</NuxtLink>
|
|
27
34
|
<div v-else>
|
|
28
35
|
<DiscussionItem
|
|
29
|
-
:title="post.content
|
|
36
|
+
:title="stripHtml(post.content || '').slice(0, 80) || 'Untitled'"
|
|
30
37
|
:author="post.author.name"
|
|
31
38
|
:reply-count="post.replyCount"
|
|
32
39
|
:vote-count="post.likeCount"
|
|
@@ -9,6 +9,11 @@ const props = defineProps<{
|
|
|
9
9
|
|
|
10
10
|
const emit = defineEmits<{ 'post-vote': [postId: string] }>();
|
|
11
11
|
|
|
12
|
+
/** Strip HTML tags for plain-text display in feed items */
|
|
13
|
+
function stripHtml(html: string): string {
|
|
14
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
const feedFilter = ref('all');
|
|
13
18
|
|
|
14
19
|
const feedFilters = [
|
|
@@ -30,8 +35,8 @@ const filteredPosts = computed(() => {
|
|
|
30
35
|
<AnnouncementBand
|
|
31
36
|
v-for="post in filteredPosts.filter(p => p.isPinned && p.type === 'announcement')"
|
|
32
37
|
:key="`ann-${post.id}`"
|
|
33
|
-
:title="post.content
|
|
34
|
-
:body="post.content || ''"
|
|
38
|
+
:title="stripHtml(post.content || '').slice(0, 80) || 'Announcement'"
|
|
39
|
+
:body="stripHtml(post.content || '')"
|
|
35
40
|
:author="post.author.name"
|
|
36
41
|
:created-at="new Date(post.createdAt)"
|
|
37
42
|
:pinned="true"
|
|
@@ -105,7 +110,7 @@ const filteredPosts = computed(() => {
|
|
|
105
110
|
<NuxtLink v-if="post.linkTo" :to="post.linkTo" class="cpub-feed-link">
|
|
106
111
|
<FeedItem
|
|
107
112
|
:type="(post.type as 'discussion' | 'question' | 'showcase' | 'announcement') || 'discussion'"
|
|
108
|
-
:title="post.content
|
|
113
|
+
:title="stripHtml(post.content || '').slice(0, 80) || ''"
|
|
109
114
|
:author="post.author.name"
|
|
110
115
|
:author-avatar="post.author.avatarUrl ?? undefined"
|
|
111
116
|
:author-handle="post.author.handle ?? undefined"
|
|
@@ -123,7 +128,7 @@ const filteredPosts = computed(() => {
|
|
|
123
128
|
<div v-else>
|
|
124
129
|
<FeedItem
|
|
125
130
|
:type="(post.type as 'discussion' | 'question' | 'showcase' | 'announcement') || 'discussion'"
|
|
126
|
-
:title="post.content
|
|
131
|
+
:title="stripHtml(post.content || '').slice(0, 80) || ''"
|
|
127
132
|
:author="post.author.name"
|
|
128
133
|
:author-avatar="post.author.avatarUrl ?? undefined"
|
|
129
134
|
:author-handle="post.author.handle ?? undefined"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for useMirrorContent composable.
|
|
3
|
+
*
|
|
4
|
+
* Tests the contentType resolution logic which determines how federated
|
|
5
|
+
* content is displayed — critical for distinguishing CommonPub vs non-CommonPub
|
|
6
|
+
* content types.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { ref, nextTick } from 'vue';
|
|
10
|
+
import { useMirrorContent } from '../useMirrorContent';
|
|
11
|
+
|
|
12
|
+
function makeFedContent(overrides: Record<string, unknown> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'fed-1',
|
|
15
|
+
objectUri: 'https://remote.example.com/content/test',
|
|
16
|
+
apType: 'Article',
|
|
17
|
+
cpubType: null,
|
|
18
|
+
title: 'Test Content',
|
|
19
|
+
content: '<p>Hello world</p>',
|
|
20
|
+
summary: 'A test',
|
|
21
|
+
url: 'https://remote.example.com/article/test',
|
|
22
|
+
coverImageUrl: null,
|
|
23
|
+
tags: [],
|
|
24
|
+
attachments: [],
|
|
25
|
+
cpubMetadata: null,
|
|
26
|
+
cpubBlocks: null,
|
|
27
|
+
localLikeCount: 0,
|
|
28
|
+
localCommentCount: 0,
|
|
29
|
+
localViewCount: 0,
|
|
30
|
+
publishedAt: '2026-03-20T10:00:00Z',
|
|
31
|
+
receivedAt: '2026-03-20T11:00:00Z',
|
|
32
|
+
originDomain: 'remote.example.com',
|
|
33
|
+
actor: {
|
|
34
|
+
actorUri: 'https://remote.example.com/users/alice',
|
|
35
|
+
preferredUsername: 'alice',
|
|
36
|
+
displayName: 'Alice',
|
|
37
|
+
avatarUrl: null,
|
|
38
|
+
instanceDomain: 'remote.example.com',
|
|
39
|
+
},
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('useMirrorContent', () => {
|
|
45
|
+
// --- contentType resolution ---
|
|
46
|
+
|
|
47
|
+
describe('contentType', () => {
|
|
48
|
+
it('returns cpubType when present (CommonPub project)', () => {
|
|
49
|
+
const fedContent = ref(makeFedContent({ cpubType: 'project' }));
|
|
50
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
51
|
+
expect(contentType.value).toBe('project');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns cpubType when present (CommonPub article)', () => {
|
|
55
|
+
const fedContent = ref(makeFedContent({ cpubType: 'article' }));
|
|
56
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
57
|
+
expect(contentType.value).toBe('article');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns cpubType when present (CommonPub blog)', () => {
|
|
61
|
+
const fedContent = ref(makeFedContent({ cpubType: 'blog' }));
|
|
62
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
63
|
+
expect(contentType.value).toBe('blog');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns cpubType when present (CommonPub explainer)', () => {
|
|
67
|
+
const fedContent = ref(makeFedContent({ cpubType: 'explainer' }));
|
|
68
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
69
|
+
expect(contentType.value).toBe('explainer');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back to apType lowercase for non-CommonPub Article', () => {
|
|
73
|
+
const fedContent = ref(makeFedContent({ cpubType: null, apType: 'Article' }));
|
|
74
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
75
|
+
expect(contentType.value).toBe('article');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to apType lowercase for Note', () => {
|
|
79
|
+
const fedContent = ref(makeFedContent({ cpubType: null, apType: 'Note' }));
|
|
80
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
81
|
+
expect(contentType.value).toBe('note');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('falls back to "article" when both cpubType and apType are null', () => {
|
|
85
|
+
const fedContent = ref(makeFedContent({ cpubType: null, apType: null }));
|
|
86
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
87
|
+
expect(contentType.value).toBe('article');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('prefers cpubType over apType', () => {
|
|
91
|
+
const fedContent = ref(makeFedContent({ cpubType: 'project', apType: 'Article' }));
|
|
92
|
+
const { contentType } = useMirrorContent(fedContent);
|
|
93
|
+
expect(contentType.value).toBe('project');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- transformedContent ---
|
|
98
|
+
|
|
99
|
+
describe('transformedContent', () => {
|
|
100
|
+
it('returns null when fedContent is null', () => {
|
|
101
|
+
const fedContent = ref(null);
|
|
102
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
103
|
+
expect(transformedContent.value).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('maps title correctly', () => {
|
|
107
|
+
const fedContent = ref(makeFedContent({ title: 'LED Cube Build' }));
|
|
108
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
109
|
+
expect(transformedContent.value?.title).toBe('LED Cube Build');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('uses "Untitled" when title is null', () => {
|
|
113
|
+
const fedContent = ref(makeFedContent({ title: null }));
|
|
114
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
115
|
+
expect(transformedContent.value?.title).toBe('Untitled');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('preserves cpubBlocks when present (CommonPub-to-CommonPub)', () => {
|
|
119
|
+
const blocks = [['paragraph', { text: 'Hello' }], ['heading', { level: 2, text: 'World' }]];
|
|
120
|
+
const fedContent = ref(makeFedContent({ cpubBlocks: blocks }));
|
|
121
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
122
|
+
expect(transformedContent.value?.content).toEqual(blocks);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('wraps HTML content as paragraph block (non-CommonPub)', () => {
|
|
126
|
+
const fedContent = ref(makeFedContent({
|
|
127
|
+
cpubBlocks: null,
|
|
128
|
+
content: '<p>Hello from Mastodon</p>',
|
|
129
|
+
}));
|
|
130
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
131
|
+
expect(transformedContent.value?.content).toEqual([
|
|
132
|
+
['paragraph', { html: '<p>Hello from Mastodon</p>' }],
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('extracts metadata from cpubMetadata', () => {
|
|
137
|
+
const fedContent = ref(makeFedContent({
|
|
138
|
+
cpubType: 'project',
|
|
139
|
+
cpubMetadata: { difficulty: 'intermediate', buildTime: '4h', estimatedCost: '$50' },
|
|
140
|
+
}));
|
|
141
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
142
|
+
expect(transformedContent.value?.difficulty).toBe('intermediate');
|
|
143
|
+
expect(transformedContent.value?.buildTime).toBe('4h');
|
|
144
|
+
expect(transformedContent.value?.estimatedCost).toBe('$50');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('maps tags to expected format', () => {
|
|
148
|
+
const fedContent = ref(makeFedContent({
|
|
149
|
+
tags: [
|
|
150
|
+
{ type: 'Hashtag', name: '#electronics' },
|
|
151
|
+
{ type: 'Hashtag', name: '#led' },
|
|
152
|
+
],
|
|
153
|
+
}));
|
|
154
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
155
|
+
expect(transformedContent.value?.tags).toHaveLength(2);
|
|
156
|
+
expect(transformedContent.value?.tags[0]?.name).toBe('#electronics');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('maps actor to author format', () => {
|
|
160
|
+
const fedContent = ref(makeFedContent({
|
|
161
|
+
actor: {
|
|
162
|
+
actorUri: 'https://remote.example.com/users/bob',
|
|
163
|
+
preferredUsername: 'bob',
|
|
164
|
+
displayName: 'Bob Builder',
|
|
165
|
+
avatarUrl: 'https://remote.example.com/avatar.png',
|
|
166
|
+
instanceDomain: 'remote.example.com',
|
|
167
|
+
followerCount: 42,
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
const { transformedContent } = useMirrorContent(fedContent);
|
|
171
|
+
expect(transformedContent.value?.author.username).toBe('bob');
|
|
172
|
+
expect(transformedContent.value?.author.displayName).toBe('Bob Builder');
|
|
173
|
+
expect(transformedContent.value?.author.avatarUrl).toBe('https://remote.example.com/avatar.png');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- originDomain ---
|
|
178
|
+
|
|
179
|
+
describe('originDomain', () => {
|
|
180
|
+
it('extracts origin domain', () => {
|
|
181
|
+
const fedContent = ref(makeFedContent({ originDomain: 'mastodon.social' }));
|
|
182
|
+
const { originDomain } = useMirrorContent(fedContent);
|
|
183
|
+
expect(originDomain.value).toBe('mastodon.social');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('falls back to "unknown" when null', () => {
|
|
187
|
+
const fedContent = ref(makeFedContent({ originDomain: null }));
|
|
188
|
+
const { originDomain } = useMirrorContent(fedContent);
|
|
189
|
+
expect(originDomain.value).toBe('unknown');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// --- authorHandle ---
|
|
194
|
+
|
|
195
|
+
describe('authorHandle', () => {
|
|
196
|
+
it('formats as @user@domain', () => {
|
|
197
|
+
const fedContent = ref(makeFedContent());
|
|
198
|
+
const { authorHandle } = useMirrorContent(fedContent);
|
|
199
|
+
expect(authorHandle.value).toBe('@alice@remote.example.com');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns empty string when no actor', () => {
|
|
203
|
+
const fedContent = ref(makeFedContent({ actor: null }));
|
|
204
|
+
const { authorHandle } = useMirrorContent(fedContent);
|
|
205
|
+
expect(authorHandle.value).toBe('');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,15 +44,22 @@
|
|
|
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/config": "0.7.1",
|
|
49
48
|
"@commonpub/protocol": "0.9.5",
|
|
50
|
-
"@commonpub/
|
|
51
|
-
"@commonpub/learning": "0.5.0",
|
|
49
|
+
"@commonpub/auth": "0.5.0",
|
|
52
50
|
"@commonpub/editor": "0.5.0",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/
|
|
51
|
+
"@commonpub/learning": "0.5.0",
|
|
52
|
+
"@commonpub/schema": "0.8.13",
|
|
53
|
+
"@commonpub/server": "2.16.0",
|
|
54
|
+
"@commonpub/docs": "0.5.2",
|
|
55
|
+
"@commonpub/ui": "0.7.1"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
59
|
+
"@testing-library/vue": "^8.1.0",
|
|
60
|
+
"@vitejs/plugin-vue": "^5.2.4",
|
|
61
|
+
"jsdom": "^25.0.1",
|
|
62
|
+
"vitest": "^3.2.4"
|
|
56
63
|
},
|
|
57
64
|
"scripts": {}
|
|
58
65
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
definePageMeta({
|
|
3
3
|
layout: 'auth',
|
|
4
|
-
middleware: 'auth'
|
|
4
|
+
// No middleware: 'auth' — this page must be accessible to unauthenticated users
|
|
5
|
+
// arriving from remote instances during federated OAuth login
|
|
5
6
|
});
|
|
6
7
|
|
|
7
8
|
useHead({ title: 'Authorize Application' });
|
|
8
9
|
|
|
9
10
|
const route = useRoute();
|
|
11
|
+
const { isAuthenticated, signIn } = useAuth();
|
|
10
12
|
|
|
11
13
|
const clientId = computed(() => route.query.client_id as string ?? '');
|
|
12
14
|
const redirectUri = computed(() => route.query.redirect_uri as string ?? '');
|
|
@@ -17,6 +19,25 @@ const state = computed(() => route.query.state as string ?? '');
|
|
|
17
19
|
const loading = ref(false);
|
|
18
20
|
const error = ref<string | null>(null);
|
|
19
21
|
|
|
22
|
+
// Login form state (shown when not authenticated)
|
|
23
|
+
const loginEmail = ref('');
|
|
24
|
+
const loginPassword = ref('');
|
|
25
|
+
const loginLoading = ref(false);
|
|
26
|
+
const loginError = ref<string | null>(null);
|
|
27
|
+
|
|
28
|
+
async function handleLogin() {
|
|
29
|
+
loginLoading.value = true;
|
|
30
|
+
loginError.value = null;
|
|
31
|
+
try {
|
|
32
|
+
await signIn(loginEmail.value, loginPassword.value);
|
|
33
|
+
// After login, stay on this page — consent form will appear
|
|
34
|
+
} catch (err: unknown) {
|
|
35
|
+
loginError.value = err instanceof Error ? err.message : 'Login failed';
|
|
36
|
+
} finally {
|
|
37
|
+
loginLoading.value = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
async function approve() {
|
|
21
42
|
loading.value = true;
|
|
22
43
|
error.value = null;
|
|
@@ -48,47 +69,111 @@ function deny() {
|
|
|
48
69
|
window.location.href = url.toString();
|
|
49
70
|
}
|
|
50
71
|
}
|
|
72
|
+
|
|
73
|
+
// Extract requesting domain from client_id for display
|
|
74
|
+
const requestingDomain = computed(() => {
|
|
75
|
+
const id = clientId.value;
|
|
76
|
+
if (id.startsWith('cpub_')) return id.slice(5);
|
|
77
|
+
try { return new URL(id).hostname; } catch { return id; }
|
|
78
|
+
});
|
|
51
79
|
</script>
|
|
52
80
|
|
|
53
81
|
<template>
|
|
54
82
|
<div class="cpub-oauth-consent">
|
|
55
|
-
|
|
83
|
+
<!-- Unauthenticated: show login form with context -->
|
|
84
|
+
<template v-if="!isAuthenticated">
|
|
85
|
+
<h1 class="cpub-oauth-consent__title">Sign in to Continue</h1>
|
|
86
|
+
|
|
87
|
+
<p class="cpub-oauth-consent__desc">
|
|
88
|
+
<strong>{{ requestingDomain }}</strong> is requesting access to your account.
|
|
89
|
+
Sign in to review and approve this request.
|
|
90
|
+
</p>
|
|
91
|
+
|
|
92
|
+
<form class="cpub-oauth-login-form" @submit.prevent="handleLogin">
|
|
93
|
+
<div v-if="loginError" class="cpub-oauth-consent__error" role="alert">
|
|
94
|
+
{{ loginError }}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<label class="cpub-oauth-login-label">
|
|
98
|
+
<span>Email</span>
|
|
99
|
+
<input
|
|
100
|
+
v-model="loginEmail"
|
|
101
|
+
type="email"
|
|
102
|
+
autocomplete="email"
|
|
103
|
+
required
|
|
104
|
+
class="cpub-oauth-login-input"
|
|
105
|
+
/>
|
|
106
|
+
</label>
|
|
107
|
+
|
|
108
|
+
<label class="cpub-oauth-login-label">
|
|
109
|
+
<span>Password</span>
|
|
110
|
+
<input
|
|
111
|
+
v-model="loginPassword"
|
|
112
|
+
type="password"
|
|
113
|
+
autocomplete="current-password"
|
|
114
|
+
required
|
|
115
|
+
class="cpub-oauth-login-input"
|
|
116
|
+
/>
|
|
117
|
+
</label>
|
|
118
|
+
|
|
119
|
+
<button
|
|
120
|
+
type="submit"
|
|
121
|
+
class="cpub-oauth-consent__btn cpub-oauth-consent__btn--approve"
|
|
122
|
+
:disabled="loginLoading"
|
|
123
|
+
>
|
|
124
|
+
{{ loginLoading ? 'Signing in...' : 'Sign in' }}
|
|
125
|
+
</button>
|
|
126
|
+
</form>
|
|
56
127
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
128
|
+
<p class="cpub-oauth-login-alt">
|
|
129
|
+
Don't have an account? <NuxtLink :to="`/auth/register?redirect=${encodeURIComponent(route.fullPath)}`">Sign up</NuxtLink>
|
|
130
|
+
</p>
|
|
131
|
+
</template>
|
|
60
132
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
133
|
+
<!-- Authenticated: show consent form -->
|
|
134
|
+
<template v-else>
|
|
135
|
+
<h1 class="cpub-oauth-consent__title">Authorize Access</h1>
|
|
136
|
+
|
|
137
|
+
<p class="cpub-oauth-consent__desc">
|
|
138
|
+
<strong>{{ requestingDomain }}</strong> is requesting access to your account.
|
|
139
|
+
</p>
|
|
140
|
+
|
|
141
|
+
<div class="cpub-oauth-consent__details">
|
|
142
|
+
<div class="cpub-oauth-consent__field">
|
|
143
|
+
<span class="cpub-oauth-consent__label">Instance</span>
|
|
144
|
+
<span class="cpub-oauth-consent__value">{{ requestingDomain }}</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div v-if="scope" class="cpub-oauth-consent__field">
|
|
147
|
+
<span class="cpub-oauth-consent__label">Scope</span>
|
|
148
|
+
<span class="cpub-oauth-consent__value">{{ scope }}</span>
|
|
149
|
+
</div>
|
|
65
150
|
</div>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<
|
|
151
|
+
|
|
152
|
+
<p class="cpub-oauth-consent__permissions">
|
|
153
|
+
This will allow <strong>{{ requestingDomain }}</strong> to verify your identity and access your public profile information.
|
|
154
|
+
</p>
|
|
155
|
+
|
|
156
|
+
<div v-if="error" class="cpub-oauth-consent__error" role="alert">
|
|
157
|
+
{{ error }}
|
|
69
158
|
</div>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
>
|
|
89
|
-
Deny
|
|
90
|
-
</button>
|
|
91
|
-
</div>
|
|
159
|
+
|
|
160
|
+
<div class="cpub-oauth-consent__actions">
|
|
161
|
+
<button
|
|
162
|
+
class="cpub-oauth-consent__btn cpub-oauth-consent__btn--approve"
|
|
163
|
+
:disabled="loading"
|
|
164
|
+
@click="approve"
|
|
165
|
+
>
|
|
166
|
+
{{ loading ? 'Authorizing...' : 'Approve' }}
|
|
167
|
+
</button>
|
|
168
|
+
<button
|
|
169
|
+
class="cpub-oauth-consent__btn cpub-oauth-consent__btn--deny"
|
|
170
|
+
:disabled="loading"
|
|
171
|
+
@click="deny"
|
|
172
|
+
>
|
|
173
|
+
Deny
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
92
177
|
</div>
|
|
93
178
|
</template>
|
|
94
179
|
|
|
@@ -136,6 +221,15 @@ function deny() {
|
|
|
136
221
|
color: var(--text-1);
|
|
137
222
|
}
|
|
138
223
|
|
|
224
|
+
.cpub-oauth-consent__permissions {
|
|
225
|
+
color: var(--text-2);
|
|
226
|
+
font-size: var(--font-size-sm);
|
|
227
|
+
margin-bottom: var(--space-4);
|
|
228
|
+
padding: var(--space-2) var(--space-3);
|
|
229
|
+
background: var(--surface-2);
|
|
230
|
+
border: var(--border-width-default) solid var(--border);
|
|
231
|
+
}
|
|
232
|
+
|
|
139
233
|
.cpub-oauth-consent__error {
|
|
140
234
|
padding: var(--space-2) var(--space-3);
|
|
141
235
|
border: var(--border-width-default) solid var(--error);
|
|
@@ -175,4 +269,46 @@ function deny() {
|
|
|
175
269
|
opacity: 0.5;
|
|
176
270
|
cursor: not-allowed;
|
|
177
271
|
}
|
|
272
|
+
|
|
273
|
+
/* Login form styles */
|
|
274
|
+
.cpub-oauth-login-form {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
gap: var(--space-3);
|
|
278
|
+
margin-bottom: var(--space-4);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.cpub-oauth-login-label {
|
|
282
|
+
display: flex;
|
|
283
|
+
flex-direction: column;
|
|
284
|
+
gap: var(--space-1);
|
|
285
|
+
font-family: var(--font-mono);
|
|
286
|
+
font-size: var(--font-size-sm);
|
|
287
|
+
text-transform: uppercase;
|
|
288
|
+
letter-spacing: 0.05em;
|
|
289
|
+
color: var(--text-2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.cpub-oauth-login-input {
|
|
293
|
+
padding: var(--space-2);
|
|
294
|
+
border: var(--border-width-default) solid var(--border);
|
|
295
|
+
background: var(--surface);
|
|
296
|
+
color: var(--text-1);
|
|
297
|
+
font-size: var(--font-size-base);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.cpub-oauth-login-input:focus {
|
|
301
|
+
border-color: var(--accent);
|
|
302
|
+
outline: none;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.cpub-oauth-login-alt {
|
|
306
|
+
font-size: var(--font-size-sm);
|
|
307
|
+
color: var(--text-2);
|
|
308
|
+
text-align: center;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.cpub-oauth-login-alt a {
|
|
312
|
+
color: var(--accent);
|
|
313
|
+
}
|
|
178
314
|
</style>
|
|
@@ -90,13 +90,16 @@ const postsVM = computed<HubPostViewModel[]>(() => {
|
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
// Extract shared content posts for "Projects" tab
|
|
93
|
+
// Extract shared content posts for "Projects" tab — filter by type to avoid lumping discussions
|
|
94
94
|
const sharedContentPosts = computed(() =>
|
|
95
|
-
postsVM.value.filter(p => p.sharedContent),
|
|
95
|
+
postsVM.value.filter(p => p.sharedContent?.type === 'project'),
|
|
96
96
|
);
|
|
97
97
|
|
|
98
98
|
const discussionPosts = computed(() =>
|
|
99
|
-
postsVM.value.filter(p =>
|
|
99
|
+
postsVM.value.filter(p =>
|
|
100
|
+
(p.type === 'text' || p.type === 'discussion' || p.type === 'question')
|
|
101
|
+
&& !p.sharedContent,
|
|
102
|
+
),
|
|
100
103
|
);
|
|
101
104
|
|
|
102
105
|
// Hub rules (from federated metadata)
|
|
@@ -87,9 +87,13 @@ async function handleReply(): Promise<void> {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function stripHtml(html: string): string {
|
|
91
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
useSeoMeta({
|
|
91
|
-
title: () => post.value ? `${post.value.content
|
|
92
|
-
description: () => post.value?.content
|
|
95
|
+
title: () => post.value ? `${stripHtml(post.value.content || '').slice(0, 60)} — ${hub.value?.name ?? 'Hub'}` : 'Post',
|
|
96
|
+
description: () => stripHtml(post.value?.content ?? '').slice(0, 160),
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
useHead({
|
|
@@ -128,7 +132,9 @@ useHead({
|
|
|
128
132
|
<span class="cpub-post-type-badge">{{ post.postType }}</span>
|
|
129
133
|
</div>
|
|
130
134
|
|
|
131
|
-
|
|
135
|
+
<!-- Content is sanitized server-side via sanitizeHtml() before storage -->
|
|
136
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
137
|
+
<div class="cpub-post-content cpub-prose" v-html="post.content"></div>
|
|
132
138
|
|
|
133
139
|
<div class="cpub-post-meta">
|
|
134
140
|
<div class="cpub-post-author">
|
|
@@ -161,7 +161,7 @@ useSeoMeta({
|
|
|
161
161
|
</div>
|
|
162
162
|
</div>
|
|
163
163
|
<div v-else class="cpub-post-content">
|
|
164
|
-
|
|
164
|
+
<MentionText :text="post.content || ''" />
|
|
165
165
|
<div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
|
|
166
166
|
<i class="fa-solid fa-pen"></i> edited
|
|
167
167
|
</div>
|
package/pages/search.vue
CHANGED
|
@@ -3,8 +3,9 @@ definePageMeta({ middleware: 'auth' });
|
|
|
3
3
|
|
|
4
4
|
const { themeId: theme, setTheme } = useTheme();
|
|
5
5
|
const themes = [
|
|
6
|
-
{ id: 'base', name: 'Light', desc: 'Sharp corners, offset shadows' },
|
|
7
|
-
{ id: 'dark', name: 'Dark', desc: 'Dark surfaces, same aesthetic' },
|
|
6
|
+
{ id: 'base', name: 'Light', desc: 'Sharp corners, offset shadows, blue accent' },
|
|
7
|
+
{ id: 'dark', name: 'Dark', desc: 'Dark surfaces, same offset shadow aesthetic' },
|
|
8
|
+
{ id: 'generics', name: 'Generics', desc: 'Dark minimal with soft glow' },
|
|
8
9
|
];
|
|
9
10
|
</script>
|
|
10
11
|
|
|
@@ -35,7 +36,7 @@ const themes = [
|
|
|
35
36
|
<style scoped>
|
|
36
37
|
.cpub-theme-grid {
|
|
37
38
|
display: grid;
|
|
38
|
-
grid-template-columns: repeat(
|
|
39
|
+
grid-template-columns: repeat(3, 1fr);
|
|
39
40
|
gap: 12px;
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -74,7 +75,7 @@ const themes = [
|
|
|
74
75
|
color: var(--text-dim);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
@media (max-width:
|
|
78
|
+
@media (max-width: 640px) {
|
|
78
79
|
.cpub-theme-grid { grid-template-columns: 1fr; }
|
|
79
80
|
}
|
|
80
81
|
</style>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
refreshFederatedHubMetadata,
|
|
8
8
|
backfillHubFromOutbox,
|
|
9
|
+
fetchRemoteHubFollowers,
|
|
9
10
|
} from '@commonpub/server';
|
|
10
11
|
import { federatedHubs } from '@commonpub/schema';
|
|
11
12
|
import { eq, and, or, lt, isNull } from 'drizzle-orm';
|
|
@@ -83,6 +84,19 @@ export default defineNitroPlugin((nitro) => {
|
|
|
83
84
|
// Refresh metadata (name, description, icon, member count)
|
|
84
85
|
await refreshFederatedHubMetadata(db, hub.id, hub.actorUri);
|
|
85
86
|
|
|
87
|
+
// Fetch followers to populate members list (first sync or periodic refresh)
|
|
88
|
+
if (!hub.lastSyncAt) {
|
|
89
|
+
// First sync — fetch followers to seed the members table
|
|
90
|
+
try {
|
|
91
|
+
const result = await fetchRemoteHubFollowers(db, hub.id, domain);
|
|
92
|
+
if (result.fetched > 0) {
|
|
93
|
+
console.log(`[hub-sync] Fetched ${result.fetched} followers for ${hub.name}`);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(`[hub-sync] Followers fetch failed for ${hub.name}:`, err instanceof Error ? err.message : err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
// Optionally backfill new posts from outbox
|
|
87
101
|
if (backfillOnSync) {
|
|
88
102
|
const result = await backfillHubFromOutbox(db, hub.id, domain);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { contentToArticle } from '@commonpub/protocol';
|
|
2
|
+
import { contentItems, users } from '@commonpub/schema';
|
|
3
|
+
import { eq, and, isNull } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content AP Article endpoint.
|
|
7
|
+
* Serves Article JSON-LD when requested with AP Accept header.
|
|
8
|
+
* Remote instances dereference this URI when processing:
|
|
9
|
+
* - Create activities (content federation)
|
|
10
|
+
* - Announce activities (hub share federation)
|
|
11
|
+
* - Like/Boost activities targeting content
|
|
12
|
+
*/
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
15
|
+
const isAPRequest =
|
|
16
|
+
accept.includes('application/activity+json') ||
|
|
17
|
+
accept.includes('application/ld+json');
|
|
18
|
+
|
|
19
|
+
if (!isAPRequest) return;
|
|
20
|
+
|
|
21
|
+
const config = useConfig();
|
|
22
|
+
if (!config.features.federation) return;
|
|
23
|
+
|
|
24
|
+
const slug = getRouterParam(event, 'slug');
|
|
25
|
+
if (!slug) return;
|
|
26
|
+
|
|
27
|
+
const db = useDB();
|
|
28
|
+
const domain = config.instance.domain;
|
|
29
|
+
|
|
30
|
+
const [row] = await db
|
|
31
|
+
.select({
|
|
32
|
+
content: contentItems,
|
|
33
|
+
author: {
|
|
34
|
+
username: users.username,
|
|
35
|
+
displayName: users.displayName,
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.from(contentItems)
|
|
39
|
+
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
40
|
+
.where(and(
|
|
41
|
+
eq(contentItems.slug, slug),
|
|
42
|
+
eq(contentItems.status, 'published'),
|
|
43
|
+
isNull(contentItems.deletedAt),
|
|
44
|
+
))
|
|
45
|
+
.limit(1);
|
|
46
|
+
|
|
47
|
+
if (!row) return;
|
|
48
|
+
|
|
49
|
+
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
50
|
+
|
|
51
|
+
// Render the content as an AP Article with all CommonPub extensions
|
|
52
|
+
const article = contentToArticle(
|
|
53
|
+
{
|
|
54
|
+
id: row.content.id,
|
|
55
|
+
type: row.content.type,
|
|
56
|
+
title: row.content.title,
|
|
57
|
+
slug: row.content.slug,
|
|
58
|
+
description: row.content.description,
|
|
59
|
+
content: typeof row.content.content === 'string'
|
|
60
|
+
? row.content.content
|
|
61
|
+
: JSON.stringify(row.content.content),
|
|
62
|
+
coverImageUrl: row.content.coverImageUrl,
|
|
63
|
+
publishedAt: row.content.publishedAt,
|
|
64
|
+
updatedAt: row.content.updatedAt,
|
|
65
|
+
},
|
|
66
|
+
{ username: row.author.username, displayName: row.author.displayName ?? row.author.username },
|
|
67
|
+
domain,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return article;
|
|
71
|
+
});
|
|
@@ -33,8 +33,10 @@ export default defineEventHandler(async (event) => {
|
|
|
33
33
|
.select({
|
|
34
34
|
id: hubPosts.id,
|
|
35
35
|
content: hubPosts.content,
|
|
36
|
+
type: hubPosts.type,
|
|
36
37
|
createdAt: hubPosts.createdAt,
|
|
37
38
|
authorUsername: users.username,
|
|
39
|
+
authorDisplayName: users.displayName,
|
|
38
40
|
})
|
|
39
41
|
.from(hubPosts)
|
|
40
42
|
.innerJoin(users, eq(hubPosts.authorId, users.id))
|
|
@@ -49,15 +51,43 @@ export default defineEventHandler(async (event) => {
|
|
|
49
51
|
|
|
50
52
|
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
51
53
|
|
|
54
|
+
// Build Note content — share posts need special handling
|
|
55
|
+
let noteContent = escapeHtmlForAP(post.content);
|
|
56
|
+
const ext: Record<string, unknown> = {};
|
|
57
|
+
|
|
58
|
+
if (post.type === 'share') {
|
|
59
|
+
try {
|
|
60
|
+
const shared = JSON.parse(post.content) as Record<string, unknown>;
|
|
61
|
+
ext['cpub:sharedContent'] = {
|
|
62
|
+
type: shared.type ?? 'article',
|
|
63
|
+
title: shared.title ?? '',
|
|
64
|
+
summary: shared.description ?? null,
|
|
65
|
+
coverImageUrl: shared.coverImageUrl ?? null,
|
|
66
|
+
originUrl: shared.slug
|
|
67
|
+
? `https://${domain}/${shared.type}/${shared.slug}`
|
|
68
|
+
: null,
|
|
69
|
+
originDomain: domain,
|
|
70
|
+
};
|
|
71
|
+
const displayName = post.authorDisplayName ?? post.authorUsername;
|
|
72
|
+
const title = shared.title ? String(shared.title) : 'content';
|
|
73
|
+
noteContent = escapeHtmlForAP(`${displayName} shared: ${title}`);
|
|
74
|
+
} catch { /* fallback to raw content */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (post.type && post.type !== 'text') {
|
|
78
|
+
ext['cpub:postType'] = post.type;
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
return {
|
|
53
82
|
'@context': AP_CONTEXT,
|
|
54
83
|
type: 'Note',
|
|
55
84
|
id: noteUri,
|
|
56
85
|
attributedTo: actorUri,
|
|
57
|
-
content:
|
|
86
|
+
content: noteContent,
|
|
58
87
|
published: post.createdAt.toISOString(),
|
|
59
88
|
to: [AP_PUBLIC],
|
|
60
89
|
cc: [`${hubActorUri}/followers`],
|
|
61
90
|
context: hubActorUri,
|
|
91
|
+
...ext,
|
|
62
92
|
};
|
|
63
93
|
});
|