@commonpub/layer 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,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>
|
|
@@ -75,7 +75,7 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
75
75
|
<i class="fa-solid fa-users"></i>
|
|
76
76
|
</div>
|
|
77
77
|
<div class="cpub-related-hub-info">
|
|
78
|
-
<NuxtLink :to="hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
|
|
78
|
+
<NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
|
|
79
79
|
<div class="cpub-related-hub-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
80
80
|
</div>
|
|
81
81
|
<button class="cpub-btn-join-sm">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,14 +44,14 @@
|
|
|
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",
|
|
48
|
+
"@commonpub/auth": "0.5.0",
|
|
49
49
|
"@commonpub/editor": "0.5.0",
|
|
50
|
-
"@commonpub/learning": "0.5.0",
|
|
51
50
|
"@commonpub/docs": "0.5.2",
|
|
51
|
+
"@commonpub/learning": "0.5.0",
|
|
52
52
|
"@commonpub/protocol": "0.9.5",
|
|
53
|
-
"@commonpub/server": "2.19.0",
|
|
54
53
|
"@commonpub/schema": "0.8.13",
|
|
54
|
+
"@commonpub/server": "2.20.1",
|
|
55
55
|
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
@@ -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/index.vue
CHANGED
|
@@ -298,7 +298,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
298
298
|
<i v-else class="fa-solid fa-users"></i>
|
|
299
299
|
</div>
|
|
300
300
|
<div class="cpub-hub-info">
|
|
301
|
-
<NuxtLink :to="hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-hub-name">{{ hub.name }}</NuxtLink>
|
|
301
|
+
<NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-hub-name">{{ hub.name }}</NuxtLink>
|
|
302
302
|
<div class="cpub-hub-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
303
303
|
</div>
|
|
304
304
|
<button v-if="joinedHubs.has(hub.slug)" class="cpub-btn-joined" disabled><i class="fa-solid fa-check"></i> Joined</button>
|
package/pages/search.vue
CHANGED
|
@@ -327,7 +327,7 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
327
327
|
<NuxtLink
|
|
328
328
|
v-for="hub in results.items"
|
|
329
329
|
:key="hub.id"
|
|
330
|
-
:to="hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`"
|
|
330
|
+
:to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`"
|
|
331
331
|
class="cpub-search-hub-card"
|
|
332
332
|
>
|
|
333
333
|
<div class="cpub-search-hub-icon">
|
|
@@ -340,7 +340,7 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
340
340
|
<div class="cpub-search-hub-meta">
|
|
341
341
|
<span><i class="fa-solid fa-users"></i> {{ hub.memberCount ?? 0 }} members</span>
|
|
342
342
|
<span><i class="fa-solid fa-message"></i> {{ hub.postCount ?? 0 }} posts</span>
|
|
343
|
-
<span v-if="hub.source === 'federated'" class="cpub-search-hub-fed"><i class="fa-solid fa-globe"></i> Federated</span>
|
|
343
|
+
<span v-if="(hub as Record<string, unknown>).source === 'federated'" class="cpub-search-hub-fed"><i class="fa-solid fa-globe"></i> Federated</span>
|
|
344
344
|
</div>
|
|
345
345
|
</div>
|
|
346
346
|
</NuxtLink>
|
|
@@ -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
|
+
});
|