@commonpub/layer 0.3.35 → 0.3.37
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/components/ImportUrlModal.vue +449 -0
- package/package.json +11 -6
- package/pages/[type]/[slug]/edit.vue +67 -1
- package/pages/search.vue +3 -2
- package/server/api/content/import.post.ts +34 -0
- package/server/api/search/index.get.ts +3 -3
- package/types/meilisearch.d.ts +5 -0
- package/components/__tests__/FederatedContentCard.test.ts +0 -340
- package/composables/__tests__/useMirrorContent.test.ts +0 -208
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
show: boolean;
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
close: [];
|
|
10
|
+
imported: [result: ImportedContent];
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
interface ImportedContent {
|
|
14
|
+
title: string;
|
|
15
|
+
description: string;
|
|
16
|
+
coverImageUrl: string | null;
|
|
17
|
+
content: BlockTuple[];
|
|
18
|
+
tags: string[];
|
|
19
|
+
partial: boolean;
|
|
20
|
+
meta: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const url = ref('');
|
|
24
|
+
const loading = ref(false);
|
|
25
|
+
const error = ref('');
|
|
26
|
+
const result = ref<ImportedContent | null>(null);
|
|
27
|
+
const confirmed = ref(false);
|
|
28
|
+
|
|
29
|
+
const canSubmit = computed(() => {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(url.value);
|
|
32
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function handleFetch(): Promise<void> {
|
|
39
|
+
if (!canSubmit.value) return;
|
|
40
|
+
loading.value = true;
|
|
41
|
+
error.value = '';
|
|
42
|
+
result.value = null;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const data = await $fetch<ImportedContent>('/api/content/import', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body: { url: url.value },
|
|
48
|
+
});
|
|
49
|
+
result.value = data;
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
const msg = (err as { data?: { message?: string } })?.data?.message
|
|
52
|
+
|| (err as Error)?.message
|
|
53
|
+
|| 'Failed to import content';
|
|
54
|
+
error.value = msg;
|
|
55
|
+
} finally {
|
|
56
|
+
loading.value = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleImport(): void {
|
|
61
|
+
if (!result.value || !confirmed.value) return;
|
|
62
|
+
emit('imported', result.value);
|
|
63
|
+
resetState();
|
|
64
|
+
emit('close');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleClose(): void {
|
|
68
|
+
resetState();
|
|
69
|
+
emit('close');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resetState(): void {
|
|
73
|
+
url.value = '';
|
|
74
|
+
loading.value = false;
|
|
75
|
+
error.value = '';
|
|
76
|
+
result.value = null;
|
|
77
|
+
confirmed.value = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
81
|
+
if (e.key === 'Escape') handleClose();
|
|
82
|
+
if (e.key === 'Enter' && !result.value && canSubmit.value && !loading.value) handleFetch();
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<Teleport to="body">
|
|
88
|
+
<div v-if="show" class="cpub-import-overlay" @click.self="handleClose" @keydown="onKeydown">
|
|
89
|
+
<div class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
|
|
90
|
+
<div class="cpub-import-header">
|
|
91
|
+
<h2 id="import-url-title"><i class="fa-solid fa-file-import"></i> Import from URL</h2>
|
|
92
|
+
<button class="cpub-import-close" aria-label="Close" @click="handleClose">
|
|
93
|
+
<i class="fa-solid fa-xmark"></i>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- URL input -->
|
|
98
|
+
<div class="cpub-import-url-row">
|
|
99
|
+
<input
|
|
100
|
+
v-model="url"
|
|
101
|
+
type="url"
|
|
102
|
+
class="cpub-import-url-input"
|
|
103
|
+
placeholder="https://example.com/article-to-import"
|
|
104
|
+
aria-label="URL to import"
|
|
105
|
+
:disabled="loading"
|
|
106
|
+
@keydown.enter.prevent="handleFetch"
|
|
107
|
+
/>
|
|
108
|
+
<button
|
|
109
|
+
class="cpub-import-fetch-btn"
|
|
110
|
+
:disabled="!canSubmit || loading"
|
|
111
|
+
@click="handleFetch"
|
|
112
|
+
>
|
|
113
|
+
<i v-if="loading" class="fa-solid fa-circle-notch fa-spin"></i>
|
|
114
|
+
<template v-else>Fetch</template>
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- Error -->
|
|
119
|
+
<div v-if="error" class="cpub-import-error" role="alert">
|
|
120
|
+
<i class="fa-solid fa-triangle-exclamation"></i> {{ error }}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Results preview -->
|
|
124
|
+
<div v-if="result" class="cpub-import-preview">
|
|
125
|
+
<div v-if="result.partial" class="cpub-import-warning">
|
|
126
|
+
<i class="fa-solid fa-exclamation-circle"></i>
|
|
127
|
+
Only partial content could be extracted. You may need to add missing sections manually.
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="cpub-import-preview-card">
|
|
131
|
+
<img
|
|
132
|
+
v-if="result.coverImageUrl"
|
|
133
|
+
:src="result.coverImageUrl"
|
|
134
|
+
alt=""
|
|
135
|
+
class="cpub-import-preview-cover"
|
|
136
|
+
/>
|
|
137
|
+
<div class="cpub-import-preview-info">
|
|
138
|
+
<h3 class="cpub-import-preview-title">{{ result.title || 'Untitled' }}</h3>
|
|
139
|
+
<p v-if="result.description" class="cpub-import-preview-desc">{{ result.description }}</p>
|
|
140
|
+
<div class="cpub-import-preview-stats">
|
|
141
|
+
<span class="cpub-import-stat">{{ result.content.length }} blocks</span>
|
|
142
|
+
<span v-if="result.tags.length" class="cpub-import-stat">{{ result.tags.length }} tags</span>
|
|
143
|
+
<span v-if="result.meta.difficulty" class="cpub-import-stat">{{ result.meta.difficulty }}</span>
|
|
144
|
+
<span v-if="result.meta.wordCount" class="cpub-import-stat">~{{ result.meta.wordCount }} words</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div v-if="result.tags.length" class="cpub-import-tags">
|
|
150
|
+
<span v-for="tag in result.tags.slice(0, 10)" :key="tag" class="cpub-import-tag">{{ tag }}</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Footer -->
|
|
155
|
+
<div v-if="result" class="cpub-import-footer">
|
|
156
|
+
<label class="cpub-import-confirm">
|
|
157
|
+
<input v-model="confirmed" type="checkbox" />
|
|
158
|
+
<span>I am the original author of this content</span>
|
|
159
|
+
</label>
|
|
160
|
+
<div class="cpub-import-actions">
|
|
161
|
+
<button class="cpub-import-cancel" @click="handleClose">Cancel</button>
|
|
162
|
+
<button
|
|
163
|
+
class="cpub-import-btn"
|
|
164
|
+
:disabled="!confirmed"
|
|
165
|
+
@click="handleImport"
|
|
166
|
+
>
|
|
167
|
+
<i class="fa-solid fa-file-import"></i> Import Content
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</Teleport>
|
|
174
|
+
</template>
|
|
175
|
+
|
|
176
|
+
<style scoped>
|
|
177
|
+
.cpub-import-overlay {
|
|
178
|
+
position: fixed;
|
|
179
|
+
inset: 0;
|
|
180
|
+
z-index: 10000;
|
|
181
|
+
background: var(--color-surface-overlay, rgba(0, 0, 0, 0.5));
|
|
182
|
+
backdrop-filter: blur(4px);
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
justify-content: center;
|
|
186
|
+
padding: 16px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.cpub-import-dialog {
|
|
190
|
+
width: 100%;
|
|
191
|
+
max-width: 560px;
|
|
192
|
+
background: var(--surface);
|
|
193
|
+
border: var(--border-width-default) solid var(--border);
|
|
194
|
+
box-shadow: var(--shadow-xl);
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
max-height: 80vh;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.cpub-import-header {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: space-between;
|
|
204
|
+
padding: 16px 20px;
|
|
205
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.cpub-import-header h2 {
|
|
209
|
+
font-family: var(--font-mono);
|
|
210
|
+
font-size: 1rem;
|
|
211
|
+
font-weight: 700;
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
letter-spacing: 0.04em;
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.cpub-import-close {
|
|
220
|
+
width: 32px;
|
|
221
|
+
height: 32px;
|
|
222
|
+
border: var(--border-width-default) solid transparent;
|
|
223
|
+
background: none;
|
|
224
|
+
color: var(--text-dim);
|
|
225
|
+
cursor: pointer;
|
|
226
|
+
font-size: 14px;
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
justify-content: center;
|
|
230
|
+
}
|
|
231
|
+
.cpub-import-close:hover {
|
|
232
|
+
background: var(--surface2);
|
|
233
|
+
border-color: var(--border);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.cpub-import-url-row {
|
|
237
|
+
display: flex;
|
|
238
|
+
gap: 0;
|
|
239
|
+
padding: 16px 20px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.cpub-import-url-input {
|
|
243
|
+
flex: 1;
|
|
244
|
+
padding: 10px 14px;
|
|
245
|
+
border: var(--border-width-default) solid var(--border);
|
|
246
|
+
border-right: none;
|
|
247
|
+
background: var(--bg);
|
|
248
|
+
color: var(--text);
|
|
249
|
+
font-family: var(--font-mono);
|
|
250
|
+
font-size: 13px;
|
|
251
|
+
outline: none;
|
|
252
|
+
}
|
|
253
|
+
.cpub-import-url-input:focus {
|
|
254
|
+
border-color: var(--accent);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.cpub-import-fetch-btn {
|
|
258
|
+
padding: 10px 20px;
|
|
259
|
+
border: var(--border-width-default) solid var(--accent);
|
|
260
|
+
background: var(--accent);
|
|
261
|
+
color: var(--color-text-inverse);
|
|
262
|
+
font-family: var(--font-mono);
|
|
263
|
+
font-size: 12px;
|
|
264
|
+
font-weight: 600;
|
|
265
|
+
letter-spacing: 0.04em;
|
|
266
|
+
text-transform: uppercase;
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
min-width: 80px;
|
|
269
|
+
display: flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
justify-content: center;
|
|
272
|
+
}
|
|
273
|
+
.cpub-import-fetch-btn:disabled {
|
|
274
|
+
opacity: 0.5;
|
|
275
|
+
cursor: not-allowed;
|
|
276
|
+
}
|
|
277
|
+
.cpub-import-fetch-btn:hover:not(:disabled) {
|
|
278
|
+
opacity: 0.85;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.cpub-import-error {
|
|
282
|
+
margin: 0 20px 12px;
|
|
283
|
+
padding: 10px 14px;
|
|
284
|
+
background: var(--red-bg, rgba(255, 80, 80, 0.08));
|
|
285
|
+
border: var(--border-width-default) solid var(--red, #f55);
|
|
286
|
+
color: var(--red, #f55);
|
|
287
|
+
font-size: 12px;
|
|
288
|
+
font-family: var(--font-mono);
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 8px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.cpub-import-preview {
|
|
295
|
+
flex: 1;
|
|
296
|
+
overflow-y: auto;
|
|
297
|
+
padding: 0 20px 16px;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.cpub-import-warning {
|
|
301
|
+
padding: 10px 14px;
|
|
302
|
+
background: var(--yellow-bg, rgba(255, 200, 50, 0.08));
|
|
303
|
+
border: var(--border-width-default) solid var(--yellow, #fc3);
|
|
304
|
+
color: var(--yellow, #fc3);
|
|
305
|
+
font-size: 12px;
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 8px;
|
|
309
|
+
margin-bottom: 12px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.cpub-import-preview-card {
|
|
313
|
+
display: flex;
|
|
314
|
+
gap: 14px;
|
|
315
|
+
padding: 14px;
|
|
316
|
+
border: var(--border-width-default) solid var(--border);
|
|
317
|
+
background: var(--bg);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.cpub-import-preview-cover {
|
|
321
|
+
width: 100px;
|
|
322
|
+
height: 72px;
|
|
323
|
+
object-fit: cover;
|
|
324
|
+
border: var(--border-width-default) solid var(--border);
|
|
325
|
+
flex-shrink: 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.cpub-import-preview-info {
|
|
329
|
+
flex: 1;
|
|
330
|
+
min-width: 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.cpub-import-preview-title {
|
|
334
|
+
font-size: 14px;
|
|
335
|
+
font-weight: 700;
|
|
336
|
+
margin-bottom: 4px;
|
|
337
|
+
overflow: hidden;
|
|
338
|
+
text-overflow: ellipsis;
|
|
339
|
+
white-space: nowrap;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.cpub-import-preview-desc {
|
|
343
|
+
font-size: 12px;
|
|
344
|
+
color: var(--text-dim);
|
|
345
|
+
margin-bottom: 8px;
|
|
346
|
+
display: -webkit-box;
|
|
347
|
+
-webkit-line-clamp: 2;
|
|
348
|
+
-webkit-box-orient: vertical;
|
|
349
|
+
overflow: hidden;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.cpub-import-preview-stats {
|
|
353
|
+
display: flex;
|
|
354
|
+
gap: 10px;
|
|
355
|
+
flex-wrap: wrap;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.cpub-import-stat {
|
|
359
|
+
font-family: var(--font-mono);
|
|
360
|
+
font-size: 10px;
|
|
361
|
+
color: var(--text-faint);
|
|
362
|
+
padding: 2px 6px;
|
|
363
|
+
background: var(--surface2);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.cpub-import-tags {
|
|
367
|
+
display: flex;
|
|
368
|
+
gap: 4px;
|
|
369
|
+
flex-wrap: wrap;
|
|
370
|
+
margin-top: 10px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.cpub-import-tag {
|
|
374
|
+
padding: 2px 8px;
|
|
375
|
+
background: var(--surface2);
|
|
376
|
+
border: var(--border-width-default) solid var(--border);
|
|
377
|
+
font-family: var(--font-mono);
|
|
378
|
+
font-size: 10px;
|
|
379
|
+
color: var(--text-dim);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.cpub-import-footer {
|
|
383
|
+
padding: 14px 20px;
|
|
384
|
+
border-top: var(--border-width-default) solid var(--border);
|
|
385
|
+
display: flex;
|
|
386
|
+
flex-direction: column;
|
|
387
|
+
gap: 12px;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.cpub-import-confirm {
|
|
391
|
+
display: flex;
|
|
392
|
+
align-items: center;
|
|
393
|
+
gap: 8px;
|
|
394
|
+
font-size: 12px;
|
|
395
|
+
color: var(--text-dim);
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
}
|
|
398
|
+
.cpub-import-confirm input {
|
|
399
|
+
accent-color: var(--accent);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.cpub-import-actions {
|
|
403
|
+
display: flex;
|
|
404
|
+
gap: 8px;
|
|
405
|
+
justify-content: flex-end;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.cpub-import-cancel {
|
|
409
|
+
padding: 7px 14px;
|
|
410
|
+
border: var(--border-width-default) solid var(--border);
|
|
411
|
+
background: var(--surface);
|
|
412
|
+
color: var(--text-dim);
|
|
413
|
+
font-size: 12px;
|
|
414
|
+
cursor: pointer;
|
|
415
|
+
}
|
|
416
|
+
.cpub-import-cancel:hover {
|
|
417
|
+
background: var(--surface2);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.cpub-import-btn {
|
|
421
|
+
padding: 7px 16px;
|
|
422
|
+
border: var(--border-width-default) solid var(--accent);
|
|
423
|
+
background: var(--accent);
|
|
424
|
+
color: var(--color-text-inverse);
|
|
425
|
+
font-size: 12px;
|
|
426
|
+
font-weight: 600;
|
|
427
|
+
cursor: pointer;
|
|
428
|
+
display: flex;
|
|
429
|
+
align-items: center;
|
|
430
|
+
gap: 6px;
|
|
431
|
+
}
|
|
432
|
+
.cpub-import-btn:disabled {
|
|
433
|
+
opacity: 0.5;
|
|
434
|
+
cursor: not-allowed;
|
|
435
|
+
}
|
|
436
|
+
.cpub-import-btn:hover:not(:disabled) {
|
|
437
|
+
opacity: 0.85;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@media (max-width: 640px) {
|
|
441
|
+
.cpub-import-dialog {
|
|
442
|
+
max-width: 100%;
|
|
443
|
+
max-height: 90vh;
|
|
444
|
+
}
|
|
445
|
+
.cpub-import-preview-cover {
|
|
446
|
+
display: none;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
"plugins",
|
|
16
16
|
"server",
|
|
17
17
|
"theme",
|
|
18
|
-
"types"
|
|
18
|
+
"types",
|
|
19
|
+
"!**/__tests__/",
|
|
20
|
+
"!**/*.test.ts",
|
|
21
|
+
"!**/*.spec.ts",
|
|
22
|
+
"!vitest.config.ts",
|
|
23
|
+
"!test-setup.ts"
|
|
19
24
|
],
|
|
20
25
|
"publishConfig": {
|
|
21
26
|
"access": "public"
|
|
@@ -46,13 +51,13 @@
|
|
|
46
51
|
"zod": "^4.3.6",
|
|
47
52
|
"@commonpub/auth": "0.5.0",
|
|
48
53
|
"@commonpub/config": "0.7.1",
|
|
49
|
-
"@commonpub/docs": "0.5.2",
|
|
50
54
|
"@commonpub/editor": "0.5.0",
|
|
51
55
|
"@commonpub/learning": "0.5.0",
|
|
56
|
+
"@commonpub/docs": "0.5.2",
|
|
52
57
|
"@commonpub/protocol": "0.9.5",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/
|
|
58
|
+
"@commonpub/schema": "0.8.13",
|
|
59
|
+
"@commonpub/server": "2.20.1",
|
|
60
|
+
"@commonpub/ui": "0.7.1"
|
|
56
61
|
},
|
|
57
62
|
"devDependencies": {
|
|
58
63
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Component } from 'vue';
|
|
3
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
3
4
|
definePageMeta({ layout: false, middleware: 'auth' });
|
|
4
5
|
|
|
5
6
|
const route = useRoute();
|
|
@@ -209,6 +210,67 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
|
|
|
209
210
|
await importMarkdown(md, importMode);
|
|
210
211
|
isDirty.value = true;
|
|
211
212
|
}
|
|
213
|
+
|
|
214
|
+
// --- URL import ---
|
|
215
|
+
const showUrlImport = ref(false);
|
|
216
|
+
const urlImporting = ref(false);
|
|
217
|
+
|
|
218
|
+
interface ImportedContent {
|
|
219
|
+
title: string;
|
|
220
|
+
description: string;
|
|
221
|
+
coverImageUrl: string | null;
|
|
222
|
+
content: BlockTuple[];
|
|
223
|
+
tags: string[];
|
|
224
|
+
partial: boolean;
|
|
225
|
+
meta: Record<string, unknown>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
229
|
+
urlImporting.value = true;
|
|
230
|
+
try {
|
|
231
|
+
// Populate title if empty
|
|
232
|
+
if (!title.value && result.title) {
|
|
233
|
+
title.value = result.title.slice(0, 255);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Populate metadata — sanitize values to match schema constraints
|
|
237
|
+
if (result.description && !metadata.value.description) {
|
|
238
|
+
metadata.value = { ...metadata.value, description: result.description.slice(0, 2000) };
|
|
239
|
+
}
|
|
240
|
+
if (result.coverImageUrl && !metadata.value.coverImageUrl) {
|
|
241
|
+
try {
|
|
242
|
+
new URL(result.coverImageUrl);
|
|
243
|
+
metadata.value = { ...metadata.value, coverImageUrl: result.coverImageUrl };
|
|
244
|
+
} catch { /* skip invalid URL */ }
|
|
245
|
+
}
|
|
246
|
+
if (result.tags.length && (!Array.isArray(metadata.value.tags) || !metadata.value.tags.length)) {
|
|
247
|
+
const safeTags = result.tags
|
|
248
|
+
.filter(t => typeof t === 'string' && t.length > 0)
|
|
249
|
+
.map(t => t.slice(0, 64))
|
|
250
|
+
.slice(0, 20);
|
|
251
|
+
metadata.value = { ...metadata.value, tags: safeTags };
|
|
252
|
+
}
|
|
253
|
+
const VALID_DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
|
254
|
+
if (result.meta.difficulty && !metadata.value.difficulty) {
|
|
255
|
+
const diff = String(result.meta.difficulty).toLowerCase();
|
|
256
|
+
if (VALID_DIFFICULTIES.includes(diff)) {
|
|
257
|
+
metadata.value = { ...metadata.value, difficulty: diff };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Insert blocks
|
|
262
|
+
blockEditor.clearBlocks();
|
|
263
|
+
let insertAt = 0;
|
|
264
|
+
for (const [type, content] of result.content) {
|
|
265
|
+
blockEditor.addBlock(type, content as Record<string, unknown>, insertAt);
|
|
266
|
+
insertAt++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
isDirty.value = true;
|
|
270
|
+
} finally {
|
|
271
|
+
urlImporting.value = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
212
274
|
</script>
|
|
213
275
|
|
|
214
276
|
<template>
|
|
@@ -229,6 +291,7 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
|
|
|
229
291
|
<div v-else class="cpub-editor-layout">
|
|
230
292
|
<PublishErrorsModal :errors="publishErrors" :show="showPublishErrors" @dismiss="dismissPublishErrors" />
|
|
231
293
|
<EditorsMarkdownImportDialog :show="showImportDialog" @close="showImportDialog = false" @import="handleMarkdownImport" />
|
|
294
|
+
<ImportUrlModal :show="showUrlImport" @close="showUrlImport = false" @imported="handleUrlImport" />
|
|
232
295
|
<!-- Top bar -->
|
|
233
296
|
<header class="cpub-editor-topbar">
|
|
234
297
|
<NuxtLink to="/" class="cpub-editor-logo" aria-label="Home">
|
|
@@ -264,8 +327,11 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
|
|
|
264
327
|
</div>
|
|
265
328
|
<div class="cpub-topbar-spacer" />
|
|
266
329
|
<div class="cpub-topbar-actions">
|
|
330
|
+
<button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="urlImporting" @click="showUrlImport = true" title="Import from URL">
|
|
331
|
+
<i class="fa-solid fa-link"></i> <span class="cpub-import-label">Import URL</span>
|
|
332
|
+
</button>
|
|
267
333
|
<button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="importing" @click="showImportDialog = true" title="Import Markdown">
|
|
268
|
-
<i class="fa-brands fa-markdown"></i> <span class="cpub-import-label">
|
|
334
|
+
<i class="fa-brands fa-markdown"></i> <span class="cpub-import-label">Markdown</span>
|
|
269
335
|
</button>
|
|
270
336
|
<button class="cpub-topbar-btn" :disabled="saving || !title" @click="silentSave">
|
|
271
337
|
{{ saving ? 'Saving...' : 'Save Draft' }}
|
package/pages/search.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { PaginatedResponse } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
useSeoMeta({
|
|
5
5
|
title: `Search — ${useSiteName()}`,
|
|
@@ -67,7 +67,8 @@ const searchQuery = computed(() => ({
|
|
|
67
67
|
community: communityFilter.value || undefined,
|
|
68
68
|
}));
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
const { data: results, status } = await useFetch<PaginatedResponse<any>>('/api/search', {
|
|
71
72
|
query: searchQuery,
|
|
72
73
|
watch: [searchQuery],
|
|
73
74
|
lazy: true,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { importFromUrl } from '@commonpub/server/import';
|
|
2
|
+
import type { ImportResult } from '@commonpub/server/import';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const importBodySchema = z.object({
|
|
6
|
+
url: z.string().url(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export default defineEventHandler(async (event): Promise<ImportResult> => {
|
|
10
|
+
requireAuth(event);
|
|
11
|
+
|
|
12
|
+
const { url } = await parseBody(event, importBodySchema);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
return await importFromUrl(url);
|
|
16
|
+
} catch (err: unknown) {
|
|
17
|
+
const message = err instanceof Error ? err.message : 'Import failed';
|
|
18
|
+
|
|
19
|
+
if (message.includes('private') || message.includes('reserved')) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: message });
|
|
21
|
+
}
|
|
22
|
+
if (message === 'Invalid URL' || message.includes('must use HTTP')) {
|
|
23
|
+
throw createError({ statusCode: 400, statusMessage: message });
|
|
24
|
+
}
|
|
25
|
+
if (message.includes('HTTP ')) {
|
|
26
|
+
throw createError({ statusCode: 502, statusMessage: `Failed to fetch URL: ${message}` });
|
|
27
|
+
}
|
|
28
|
+
if (message.includes('Too many redirects') || message.includes('too large')) {
|
|
29
|
+
throw createError({ statusCode: 400, statusMessage: message });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw createError({ statusCode: 500, statusMessage: 'Content import failed' });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { searchContent, listHubs, escapeLike } from '@commonpub/server';
|
|
2
|
-
import type { ContentSearchOptions } from '@commonpub/server';
|
|
2
|
+
import type { ContentSearchOptions, MeiliClient } from '@commonpub/server';
|
|
3
3
|
import { users, follows, hubs } from '@commonpub/schema';
|
|
4
4
|
import { sql, desc, ilike, or, and, isNull, eq } from 'drizzle-orm';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -45,7 +45,7 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
|
|
|
45
45
|
bannerUrl: hub.bannerUrl,
|
|
46
46
|
memberCount: hub.memberCount,
|
|
47
47
|
postCount: hub.postCount,
|
|
48
|
-
source: (hub as Record<string, unknown>).source ?? 'local',
|
|
48
|
+
source: (hub as unknown as Record<string, unknown>).source ?? 'local',
|
|
49
49
|
})),
|
|
50
50
|
total: result.total,
|
|
51
51
|
};
|
|
@@ -108,7 +108,7 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
|
|
|
108
108
|
const meiliKey = process.env.MEILI_MASTER_KEY;
|
|
109
109
|
if (meiliUrl) {
|
|
110
110
|
const { MeiliSearch } = await import('meilisearch');
|
|
111
|
-
meiliClient = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey });
|
|
111
|
+
meiliClient = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey }) as unknown as MeiliClient;
|
|
112
112
|
}
|
|
113
113
|
} catch { /* Meilisearch not available */ }
|
|
114
114
|
|
|
@@ -1,340 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,208 +0,0 @@
|
|
|
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
|
-
});
|