@commonpub/layer 0.76.0 → 0.80.0
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/ImageCropperModal.vue +342 -0
- package/components/ImageUpload.vue +43 -10
- package/components/RegistryDirectory.vue +29 -20
- package/components/editors/BlogEditor.vue +10 -0
- package/components/hub/HubHero.vue +1 -1
- package/composables/useContentSave.ts +59 -0
- package/composables/useFocusTrap.ts +9 -0
- package/layouts/default.vue +1 -1
- package/package.json +8 -7
- package/pages/admin/federation.vue +37 -7
- package/pages/hubs/index.vue +1 -1
- package/pages/terms.vue +128 -42
- package/pages/u/[username]/[type]/[slug]/edit.vue +21 -0
- package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -0
- package/server/api/admin/registry/directory.get.ts +56 -0
- package/server/api/content/[id]/schedule.post.ts +23 -0
- package/server/plugins/scheduled-publishing.ts +51 -0
- package/utils/safeStyle.ts +16 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Reusable image cropper modal. Shows a fixed aspect-ratio crop frame; the user
|
|
4
|
+
* drags to reposition and zooms (slider / +/- / scroll / pinch) the image behind
|
|
5
|
+
* it, so the crop is WYSIWYG for the target element (avatar 1:1, banner 4:1,
|
|
6
|
+
* cover 16:9). Emits a cropped Blob. Built on vue-advanced-cropper for correct
|
|
7
|
+
* EXIF/touch/retina handling; styled entirely to the design system.
|
|
8
|
+
*/
|
|
9
|
+
import { Cropper } from 'vue-advanced-cropper';
|
|
10
|
+
import 'vue-advanced-cropper/dist/style.css';
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
/** Source image as a data/object URL. */
|
|
14
|
+
src: string;
|
|
15
|
+
/** Crop aspect ratio (width / height), e.g. 1, 4, 16/9. */
|
|
16
|
+
aspectRatio: number;
|
|
17
|
+
/** Show a circular mask hint over the (still square) crop — for avatars.
|
|
18
|
+
* Avatars also export as PNG to preserve transparency (e.g. logos). */
|
|
19
|
+
round?: boolean;
|
|
20
|
+
/** Max output width in px; height derives from the aspect ratio. */
|
|
21
|
+
outputWidth?: number;
|
|
22
|
+
title?: string;
|
|
23
|
+
}>(), {
|
|
24
|
+
round: false,
|
|
25
|
+
outputWidth: 1600,
|
|
26
|
+
title: 'Crop image',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{ crop: [blob: Blob]; cancel: [] }>();
|
|
30
|
+
|
|
31
|
+
// vue-advanced-cropper's instance methods (getResult/zoom) aren't in its exported
|
|
32
|
+
// types; reference them through a loose handle.
|
|
33
|
+
interface CropperHandle {
|
|
34
|
+
getResult: () => { coordinates?: { width: number; height: number }; canvas?: HTMLCanvasElement };
|
|
35
|
+
zoom: (factor: number) => void;
|
|
36
|
+
}
|
|
37
|
+
const cropperRef = ref<CropperHandle | null>(null);
|
|
38
|
+
|
|
39
|
+
// Modal a11y: focus trap, scroll lock, Esc, focus restore (shared pattern).
|
|
40
|
+
const dialogRef = ref<HTMLElement | null>(null);
|
|
41
|
+
const visible = ref(false);
|
|
42
|
+
onMounted(() => { visible.value = true; });
|
|
43
|
+
useFocusTrap(dialogRef, () => visible.value, () => emit('cancel'));
|
|
44
|
+
|
|
45
|
+
const ZOOM_MAX = 5;
|
|
46
|
+
const zoom = ref(1);
|
|
47
|
+
/** Crop width (in image px) at min zoom = "fit". image-restriction keeps the
|
|
48
|
+
* image covering the stencil, so this is the largest crop width ever seen. */
|
|
49
|
+
let fitCropWidth = 0;
|
|
50
|
+
const saving = ref(false);
|
|
51
|
+
|
|
52
|
+
// Reset zoom bookkeeping when the source image changes (defensive — today the
|
|
53
|
+
// parent remounts the modal per file, but don't rely on that).
|
|
54
|
+
watch(() => props.src, () => { fitCropWidth = 0; zoom.value = 1; });
|
|
55
|
+
|
|
56
|
+
const stencilProps = computed(() => ({
|
|
57
|
+
aspectRatio: props.aspectRatio,
|
|
58
|
+
movable: false,
|
|
59
|
+
resizable: false,
|
|
60
|
+
// The frame is fixed; the user manipulates the image, not the stencil. Don't
|
|
61
|
+
// render resize handles/lines (they'd be dead, non-draggable decorations).
|
|
62
|
+
handlers: {},
|
|
63
|
+
lines: {},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
function stencilSize({ boundaries }: { boundaries: { width: number; height: number } }): { width: number; height: number } {
|
|
67
|
+
let w = boundaries.width;
|
|
68
|
+
let h = w / props.aspectRatio;
|
|
69
|
+
if (h > boundaries.height) {
|
|
70
|
+
h = boundaries.height;
|
|
71
|
+
w = h * props.aspectRatio;
|
|
72
|
+
}
|
|
73
|
+
return { width: Math.round(w * 0.92), height: Math.round(h * 0.92) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const exportMime = computed(() => (props.round ? 'image/png' : 'image/jpeg'));
|
|
77
|
+
const canvasOpts = computed(() => ({
|
|
78
|
+
maxWidth: props.outputWidth,
|
|
79
|
+
maxHeight: Math.round(props.outputWidth / props.aspectRatio),
|
|
80
|
+
// Only flatten onto white for JPEG (no alpha). PNG keeps transparency.
|
|
81
|
+
...(props.round ? {} : { fillColor: '#ffffff' }),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
function onChange(result: { coordinates?: { width: number } }): void {
|
|
85
|
+
const w = result?.coordinates?.width;
|
|
86
|
+
if (!w) return;
|
|
87
|
+
if (w > fitCropWidth) fitCropWidth = w;
|
|
88
|
+
if (fitCropWidth > 0) zoom.value = Math.min(ZOOM_MAX, Math.max(1, fitCropWidth / w));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyZoom(target: number): void {
|
|
92
|
+
const c = cropperRef.value;
|
|
93
|
+
if (!c || !fitCropWidth) return;
|
|
94
|
+
const curW = c.getResult().coordinates?.width;
|
|
95
|
+
if (!curW) return;
|
|
96
|
+
const t = Math.min(ZOOM_MAX, Math.max(1, target));
|
|
97
|
+
const factor = curW / (fitCropWidth / t);
|
|
98
|
+
if (Number.isFinite(factor) && factor > 0) c.zoom(factor);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onSlider(e: Event): void { applyZoom(Number((e.target as HTMLInputElement).value)); }
|
|
102
|
+
function zoomIn(): void { applyZoom(zoom.value * 1.25); }
|
|
103
|
+
function zoomOut(): void { applyZoom(zoom.value / 1.25); }
|
|
104
|
+
|
|
105
|
+
function apply(): void {
|
|
106
|
+
const c = cropperRef.value;
|
|
107
|
+
if (!c) return;
|
|
108
|
+
const { canvas } = c.getResult();
|
|
109
|
+
if (!canvas) { emit('cancel'); return; }
|
|
110
|
+
saving.value = true;
|
|
111
|
+
canvas.toBlob((blob) => {
|
|
112
|
+
saving.value = false;
|
|
113
|
+
if (blob) emit('crop', blob); else emit('cancel');
|
|
114
|
+
}, exportMime.value, 0.92);
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<template>
|
|
119
|
+
<Teleport to="body">
|
|
120
|
+
<div class="cpub-crop-overlay" @click.self="emit('cancel')">
|
|
121
|
+
<div ref="dialogRef" class="cpub-crop-modal" role="dialog" aria-modal="true" :aria-label="title">
|
|
122
|
+
<header class="cpub-crop-head">
|
|
123
|
+
<span class="cpub-crop-title">{{ title }}</span>
|
|
124
|
+
<button type="button" class="cpub-crop-x" aria-label="Cancel" @click="emit('cancel')">
|
|
125
|
+
<i class="fa-solid fa-xmark"></i>
|
|
126
|
+
</button>
|
|
127
|
+
</header>
|
|
128
|
+
|
|
129
|
+
<div class="cpub-crop-stage" :class="{ 'cpub-crop-stage-round': round }">
|
|
130
|
+
<Cropper
|
|
131
|
+
ref="cropperRef"
|
|
132
|
+
class="cpub-cropper"
|
|
133
|
+
:src="src"
|
|
134
|
+
:stencil-props="stencilProps"
|
|
135
|
+
:stencil-size="stencilSize"
|
|
136
|
+
image-restriction="stencil"
|
|
137
|
+
:resize-image="{ touch: true, wheel: { ratio: 0.1 }, adjustStencil: false }"
|
|
138
|
+
:move-image="{ touch: true, mouse: true }"
|
|
139
|
+
:canvas="canvasOpts"
|
|
140
|
+
:transitions="true"
|
|
141
|
+
@change="onChange"
|
|
142
|
+
/>
|
|
143
|
+
<div v-if="round" class="cpub-crop-round-mask" aria-hidden="true"></div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="cpub-crop-controls">
|
|
147
|
+
<button type="button" class="cpub-crop-zbtn" aria-label="Zoom out" @click="zoomOut">
|
|
148
|
+
<i class="fa-solid fa-minus"></i>
|
|
149
|
+
</button>
|
|
150
|
+
<input
|
|
151
|
+
class="cpub-crop-slider"
|
|
152
|
+
type="range"
|
|
153
|
+
:min="1"
|
|
154
|
+
:max="ZOOM_MAX"
|
|
155
|
+
step="0.01"
|
|
156
|
+
:value="zoom"
|
|
157
|
+
aria-label="Zoom"
|
|
158
|
+
@input="onSlider"
|
|
159
|
+
/>
|
|
160
|
+
<button type="button" class="cpub-crop-zbtn" aria-label="Zoom in" @click="zoomIn">
|
|
161
|
+
<i class="fa-solid fa-plus"></i>
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<p class="cpub-crop-hint">Drag to reposition. Scroll, pinch, or use the slider to zoom.</p>
|
|
166
|
+
|
|
167
|
+
<footer class="cpub-crop-foot">
|
|
168
|
+
<button type="button" class="cpub-btn" @click="emit('cancel')">Cancel</button>
|
|
169
|
+
<button type="button" class="cpub-btn cpub-btn-primary" :disabled="saving" @click="apply">
|
|
170
|
+
{{ saving ? 'Saving...' : 'Apply' }}
|
|
171
|
+
</button>
|
|
172
|
+
</footer>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</Teleport>
|
|
176
|
+
</template>
|
|
177
|
+
|
|
178
|
+
<style scoped>
|
|
179
|
+
.cpub-crop-overlay {
|
|
180
|
+
position: fixed;
|
|
181
|
+
inset: 0;
|
|
182
|
+
z-index: var(--z-modal, 9999);
|
|
183
|
+
background: var(--color-surface-scrim, rgba(0, 0, 0, 0.6));
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: center;
|
|
187
|
+
padding: 20px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.cpub-crop-modal {
|
|
191
|
+
width: 100%;
|
|
192
|
+
max-width: 560px;
|
|
193
|
+
background: var(--surface);
|
|
194
|
+
border: var(--border-width-default, 2px) solid var(--border);
|
|
195
|
+
box-shadow: var(--shadow-lg, 6px 6px 0 var(--border));
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.cpub-crop-head {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: space-between;
|
|
204
|
+
padding: 12px 16px;
|
|
205
|
+
border-bottom: var(--border-width-default, 2px) solid var(--border);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.cpub-crop-title {
|
|
209
|
+
font-family: var(--font-mono);
|
|
210
|
+
font-size: 0.75rem;
|
|
211
|
+
font-weight: 600;
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
letter-spacing: 0.06em;
|
|
214
|
+
color: var(--text);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.cpub-crop-x {
|
|
218
|
+
background: none;
|
|
219
|
+
border: none;
|
|
220
|
+
color: var(--text-dim);
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
font-size: 1rem;
|
|
223
|
+
padding: 4px;
|
|
224
|
+
line-height: 1;
|
|
225
|
+
}
|
|
226
|
+
.cpub-crop-x:hover { color: var(--text); }
|
|
227
|
+
.cpub-crop-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
228
|
+
|
|
229
|
+
.cpub-crop-stage {
|
|
230
|
+
position: relative;
|
|
231
|
+
background: var(--surface2, var(--color-surface));
|
|
232
|
+
height: 340px;
|
|
233
|
+
overflow: hidden;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.cpub-cropper {
|
|
237
|
+
height: 100%;
|
|
238
|
+
width: 100%;
|
|
239
|
+
background: transparent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Circular hint for avatars (the stored crop stays square). */
|
|
243
|
+
.cpub-crop-round-mask {
|
|
244
|
+
position: absolute;
|
|
245
|
+
inset: 0;
|
|
246
|
+
pointer-events: none;
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: center;
|
|
250
|
+
}
|
|
251
|
+
.cpub-crop-round-mask::after {
|
|
252
|
+
content: '';
|
|
253
|
+
height: 92%;
|
|
254
|
+
aspect-ratio: 1;
|
|
255
|
+
border-radius: 50%;
|
|
256
|
+
border: var(--border-width-default, 2px) dashed var(--accent);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Style vue-advanced-cropper internals to the design system. */
|
|
260
|
+
.cpub-cropper :deep(.vue-advanced-cropper__background),
|
|
261
|
+
.cpub-cropper :deep(.vue-advanced-cropper__foreground) {
|
|
262
|
+
background: var(--color-surface-scrim, rgba(0, 0, 0, 0.55));
|
|
263
|
+
}
|
|
264
|
+
/* Belt-and-suspenders with handlers:{}/lines:{}: never show the resize chrome. */
|
|
265
|
+
.cpub-cropper :deep(.vue-line-wrapper),
|
|
266
|
+
.cpub-cropper :deep(.vue-handler-wrapper) {
|
|
267
|
+
display: none;
|
|
268
|
+
}
|
|
269
|
+
/* One clean accent frame around the crop window (outline = no layout shift,
|
|
270
|
+
no per-side border doubling). Verified via headless render: the crop window is
|
|
271
|
+
`.vue-rectangle-stencil`. */
|
|
272
|
+
.cpub-cropper :deep(.vue-rectangle-stencil) {
|
|
273
|
+
outline: var(--border-width-default, 2px) solid var(--accent);
|
|
274
|
+
outline-offset: calc(-1 * var(--border-width-default, 2px));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.cpub-crop-controls {
|
|
278
|
+
display: flex;
|
|
279
|
+
align-items: center;
|
|
280
|
+
gap: 12px;
|
|
281
|
+
padding: 14px 16px 4px;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.cpub-crop-zbtn {
|
|
285
|
+
flex: 0 0 auto;
|
|
286
|
+
width: 30px;
|
|
287
|
+
height: 30px;
|
|
288
|
+
display: inline-flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
justify-content: center;
|
|
291
|
+
background: var(--surface);
|
|
292
|
+
border: var(--border-width-default, 2px) solid var(--border);
|
|
293
|
+
color: var(--text-dim);
|
|
294
|
+
cursor: pointer;
|
|
295
|
+
}
|
|
296
|
+
.cpub-crop-zbtn:hover { border-color: var(--accent); color: var(--accent); }
|
|
297
|
+
.cpub-crop-zbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
298
|
+
|
|
299
|
+
.cpub-crop-slider {
|
|
300
|
+
flex: 1;
|
|
301
|
+
appearance: none;
|
|
302
|
+
height: 4px;
|
|
303
|
+
background: var(--border);
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
}
|
|
306
|
+
.cpub-crop-slider:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
307
|
+
.cpub-crop-slider::-webkit-slider-thumb {
|
|
308
|
+
appearance: none;
|
|
309
|
+
width: 16px;
|
|
310
|
+
height: 16px;
|
|
311
|
+
border-radius: 0;
|
|
312
|
+
background: var(--accent);
|
|
313
|
+
border: var(--border-width-default, 2px) solid var(--accent);
|
|
314
|
+
cursor: pointer;
|
|
315
|
+
}
|
|
316
|
+
.cpub-crop-slider::-moz-range-thumb {
|
|
317
|
+
width: 14px;
|
|
318
|
+
height: 14px;
|
|
319
|
+
background: var(--accent);
|
|
320
|
+
border: var(--border-width-default, 2px) solid var(--accent);
|
|
321
|
+
border-radius: 0;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.cpub-crop-hint {
|
|
326
|
+
font-size: 0.6875rem;
|
|
327
|
+
color: var(--text-faint);
|
|
328
|
+
text-align: center;
|
|
329
|
+
padding: 4px 16px 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.cpub-crop-foot {
|
|
333
|
+
display: flex;
|
|
334
|
+
justify-content: flex-end;
|
|
335
|
+
gap: 10px;
|
|
336
|
+
padding: 16px;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@media (max-width: 560px) {
|
|
340
|
+
.cpub-crop-stage { height: 280px; }
|
|
341
|
+
}
|
|
342
|
+
</style>
|
|
@@ -5,6 +5,8 @@ const props = defineProps<{
|
|
|
5
5
|
label?: string;
|
|
6
6
|
hint?: string;
|
|
7
7
|
aspectClass?: string;
|
|
8
|
+
/** Override the crop aspect ratio (width/height). Defaults by purpose. */
|
|
9
|
+
aspectRatio?: number;
|
|
8
10
|
}>();
|
|
9
11
|
|
|
10
12
|
const emit = defineEmits<{
|
|
@@ -15,31 +17,50 @@ const uploading = ref(false);
|
|
|
15
17
|
const error = ref('');
|
|
16
18
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|
17
19
|
|
|
20
|
+
// The image is cropped to the target element's aspect ratio before upload, so it
|
|
21
|
+
// displays WYSIWYG instead of being arbitrarily cover-cropped on the page.
|
|
22
|
+
const cropConfig = computed(() => {
|
|
23
|
+
switch (props.purpose) {
|
|
24
|
+
case 'avatar': return { ratio: props.aspectRatio ?? 1, round: true, output: 512, title: 'Crop avatar' };
|
|
25
|
+
case 'banner': return { ratio: props.aspectRatio ?? 4, round: false, output: 1600, title: 'Crop banner' };
|
|
26
|
+
case 'cover': return { ratio: props.aspectRatio ?? 16 / 9, round: false, output: 1600, title: 'Crop cover image' };
|
|
27
|
+
default: return { ratio: props.aspectRatio ?? 1, round: false, output: 1200, title: 'Crop image' };
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const cropSrc = ref('');
|
|
32
|
+
|
|
18
33
|
function triggerPicker(): void {
|
|
19
34
|
fileInput.value?.click();
|
|
20
35
|
}
|
|
21
36
|
|
|
22
|
-
|
|
37
|
+
function handleFileChange(event: Event): void {
|
|
23
38
|
const target = event.target as HTMLInputElement;
|
|
24
39
|
const file = target.files?.[0];
|
|
25
40
|
if (!file) return;
|
|
26
41
|
|
|
27
42
|
if (!file.type.startsWith('image/')) {
|
|
28
43
|
error.value = 'Please select an image file.';
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (file.size > 10 * 1024 * 1024) {
|
|
44
|
+
} else if (file.size > 10 * 1024 * 1024) {
|
|
33
45
|
error.value = 'Image must be under 10MB.';
|
|
34
|
-
|
|
46
|
+
} else {
|
|
47
|
+
error.value = '';
|
|
48
|
+
const reader = new FileReader();
|
|
49
|
+
reader.onload = () => { cropSrc.value = reader.result as string; };
|
|
50
|
+
reader.onerror = () => { error.value = 'Could not read the image.'; };
|
|
51
|
+
reader.readAsDataURL(file);
|
|
35
52
|
}
|
|
53
|
+
target.value = '';
|
|
54
|
+
}
|
|
36
55
|
|
|
37
|
-
|
|
56
|
+
async function onCropped(blob: Blob): Promise<void> {
|
|
57
|
+
cropSrc.value = '';
|
|
38
58
|
uploading.value = true;
|
|
39
|
-
|
|
59
|
+
error.value = '';
|
|
40
60
|
try {
|
|
61
|
+
const ext = blob.type === 'image/png' ? 'png' : 'jpg';
|
|
41
62
|
const formData = new FormData();
|
|
42
|
-
formData.append('file',
|
|
63
|
+
formData.append('file', new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' }));
|
|
43
64
|
formData.append('purpose', props.purpose);
|
|
44
65
|
|
|
45
66
|
const result = await $fetch<{ url: string }>('/api/files/upload', {
|
|
@@ -52,7 +73,6 @@ async function handleFileChange(event: Event): Promise<void> {
|
|
|
52
73
|
error.value = (err as { data?: { statusMessage?: string } })?.data?.statusMessage || 'Upload failed.';
|
|
53
74
|
} finally {
|
|
54
75
|
uploading.value = false;
|
|
55
|
-
if (target) target.value = '';
|
|
56
76
|
}
|
|
57
77
|
}
|
|
58
78
|
|
|
@@ -94,6 +114,19 @@ function clearImage(): void {
|
|
|
94
114
|
|
|
95
115
|
<span v-if="hint && !error" class="cpub-img-hint">{{ hint }}</span>
|
|
96
116
|
<span v-if="error" class="cpub-img-error">{{ error }}</span>
|
|
117
|
+
|
|
118
|
+
<ClientOnly>
|
|
119
|
+
<ImageCropperModal
|
|
120
|
+
v-if="cropSrc"
|
|
121
|
+
:src="cropSrc"
|
|
122
|
+
:aspect-ratio="cropConfig.ratio"
|
|
123
|
+
:round="cropConfig.round"
|
|
124
|
+
:output-width="cropConfig.output"
|
|
125
|
+
:title="cropConfig.title"
|
|
126
|
+
@crop="onCropped"
|
|
127
|
+
@cancel="cropSrc = ''"
|
|
128
|
+
/>
|
|
129
|
+
</ClientOnly>
|
|
97
130
|
</div>
|
|
98
131
|
</template>
|
|
99
132
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
// `id`/`status` are absent on the public remote-registry directory (read-only view);
|
|
3
|
+
// present only on the local owner view (actAsRegistry).
|
|
2
4
|
interface RegistryRow {
|
|
3
|
-
id
|
|
5
|
+
id?: string;
|
|
4
6
|
domain: string;
|
|
5
7
|
actorUri: string;
|
|
6
8
|
name: string | null;
|
|
@@ -10,24 +12,29 @@ interface RegistryRow {
|
|
|
10
12
|
localPostCount: number;
|
|
11
13
|
softwareName: string | null;
|
|
12
14
|
softwareVersion: string | null;
|
|
13
|
-
status
|
|
15
|
+
status?: string;
|
|
14
16
|
lastPingAt: string | null;
|
|
15
17
|
online: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
defineProps<{ instances: RegistryRow[]; announcingTo?: string | null }>();
|
|
20
|
+
defineProps<{ instances: RegistryRow[]; announcingTo?: string | null; readonlyMode?: boolean }>();
|
|
19
21
|
const emit = defineEmits<{ changed: []; search: [value: string] }>();
|
|
20
22
|
|
|
21
23
|
const toast = useToast();
|
|
22
24
|
const searchTerm = ref('');
|
|
23
|
-
const
|
|
25
|
+
const busyKey = ref<string | null>(null);
|
|
26
|
+
|
|
27
|
+
/** Stable per-row key — domain is unique in a registry; remote rows have no id. */
|
|
28
|
+
function rowKey(r: RegistryRow): string {
|
|
29
|
+
return r.id ?? r.domain;
|
|
30
|
+
}
|
|
24
31
|
|
|
25
32
|
function onSearch(): void {
|
|
26
33
|
emit('search', searchTerm.value.trim());
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
async function mirror(row: RegistryRow, direction: 'pull' | 'push'): Promise<void> {
|
|
30
|
-
|
|
37
|
+
busyKey.value = rowKey(row);
|
|
31
38
|
try {
|
|
32
39
|
await $fetch('/api/admin/federation/mirrors', {
|
|
33
40
|
method: 'POST',
|
|
@@ -40,12 +47,13 @@ async function mirror(row: RegistryRow, direction: 'pull' | 'push'): Promise<voi
|
|
|
40
47
|
} catch {
|
|
41
48
|
toast.error(direction === 'pull' ? 'Failed to add mirror' : 'Failed to send request');
|
|
42
49
|
} finally {
|
|
43
|
-
|
|
50
|
+
busyKey.value = null;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocked'): Promise<void> {
|
|
48
|
-
|
|
55
|
+
if (!row.id) return;
|
|
56
|
+
busyKey.value = rowKey(row);
|
|
49
57
|
const url: string = `/api/admin/registry/instances/${row.id}/status`;
|
|
50
58
|
try {
|
|
51
59
|
await $fetch(url, { method: 'POST', body: { status } });
|
|
@@ -54,7 +62,7 @@ async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocke
|
|
|
54
62
|
} catch {
|
|
55
63
|
toast.error('Failed to update instance');
|
|
56
64
|
} finally {
|
|
57
|
-
|
|
65
|
+
busyKey.value = null;
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
</script>
|
|
@@ -63,11 +71,10 @@ async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocke
|
|
|
63
71
|
<div>
|
|
64
72
|
<p class="cpub-fed-explain">
|
|
65
73
|
The <strong>registry</strong> lists CommonPub instances that announce themselves here. Mirror
|
|
66
|
-
one to pull its content, or request it to mirror you (CommonPub-to-CommonPub)
|
|
67
|
-
an entry to curate the public directory.
|
|
74
|
+
one to pull its content, or request it to mirror you (CommonPub-to-CommonPub).<template v-if="!readonlyMode"> Hide or block an entry to curate the public directory.</template>
|
|
68
75
|
</p>
|
|
69
76
|
<p v-if="announcingTo" class="cpub-fed-info-text" style="margin-bottom: 12px;">
|
|
70
|
-
This instance is announcing itself to <strong>{{ announcingTo }}</strong
|
|
77
|
+
This instance is announcing itself to <strong>{{ announcingTo }}</strong>{{ readonlyMode ? ', showing every instance registered there' : '' }}.
|
|
71
78
|
</p>
|
|
72
79
|
|
|
73
80
|
<form class="cpub-fed-form" style="margin-bottom: 12px;" @submit.prevent="onSearch">
|
|
@@ -83,20 +90,22 @@ async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocke
|
|
|
83
90
|
|
|
84
91
|
<div class="cpub-fed-activity-list">
|
|
85
92
|
<div v-if="!instances.length" class="cpub-fed-empty">No instances registered yet.</div>
|
|
86
|
-
<div v-for="i in instances" :key="i
|
|
93
|
+
<div v-for="i in instances" :key="rowKey(i)" class="cpub-fed-activity-row">
|
|
87
94
|
<span class="cpub-reg-dot" :class="{ online: i.online }" :title="i.online ? 'online' : 'offline'" aria-hidden="true"></span>
|
|
88
95
|
<span class="cpub-fed-type">{{ i.name || i.domain }}</span>
|
|
89
96
|
<span class="cpub-fed-actor">
|
|
90
97
|
{{ i.domain }} · {{ i.userCount }} users · {{ i.localPostCount }} posts<template v-if="i.softwareName"> · {{ i.softwareName }} {{ i.softwareVersion }}</template>
|
|
91
98
|
</span>
|
|
92
|
-
<span v-if="i.status !== 'active'" class="cpub-fed-status" :class="i.status === 'blocked' ? 'failed' : 'paused'">{{ i.status }}</span>
|
|
93
|
-
<button class="cpub-fed-btn-sm" :disabled="
|
|
94
|
-
<button class="cpub-fed-btn-sm" :disabled="
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
<span v-if="i.status && i.status !== 'active'" class="cpub-fed-status" :class="i.status === 'blocked' ? 'failed' : 'paused'">{{ i.status }}</span>
|
|
100
|
+
<button class="cpub-fed-btn-sm" :disabled="busyKey === rowKey(i)" @click="mirror(i, 'pull')">Mirror</button>
|
|
101
|
+
<button class="cpub-fed-btn-sm" :disabled="busyKey === rowKey(i)" @click="mirror(i, 'push')">Request mirror</button>
|
|
102
|
+
<template v-if="!readonlyMode">
|
|
103
|
+
<button v-if="i.status === 'active'" class="cpub-fed-btn-sm" :disabled="busyKey === rowKey(i)" @click="setStatus(i, 'hidden')">Hide</button>
|
|
104
|
+
<button v-else-if="i.status === 'hidden'" class="cpub-fed-btn-sm" :disabled="busyKey === rowKey(i)" @click="setStatus(i, 'active')">Unhide</button>
|
|
105
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" :disabled="busyKey === rowKey(i)" @click="setStatus(i, i.status === 'blocked' ? 'active' : 'blocked')">
|
|
106
|
+
{{ i.status === 'blocked' ? 'Unblock' : 'Block' }}
|
|
107
|
+
</button>
|
|
108
|
+
</template>
|
|
100
109
|
</div>
|
|
101
110
|
</div>
|
|
102
111
|
</div>
|
|
@@ -142,7 +142,17 @@ const siteDomain = computed(() => {
|
|
|
142
142
|
const seoDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
|
|
143
143
|
|
|
144
144
|
// --- Schedule ---
|
|
145
|
+
// Open the schedule control automatically when editing an already-scheduled post
|
|
146
|
+
// (metadata loads asynchronously, hence the immediate watch rather than a one-shot init).
|
|
145
147
|
const scheduleEnabled = ref(false);
|
|
148
|
+
watch(() => props.metadata.scheduledAt, (v) => {
|
|
149
|
+
if (v) scheduleEnabled.value = true;
|
|
150
|
+
}, { immediate: true });
|
|
151
|
+
// Turning the toggle off discards any pending schedule time so the Schedule
|
|
152
|
+
// button (gated on metadata.scheduledAt) and saved drafts don't keep a stale value.
|
|
153
|
+
watch(scheduleEnabled, (on) => {
|
|
154
|
+
if (!on && props.metadata.scheduledAt) updateMeta('scheduledAt', '');
|
|
155
|
+
});
|
|
146
156
|
|
|
147
157
|
// --- Mobile sidebar toggles ---
|
|
148
158
|
const mobileLeftOpen = ref(false);
|
|
@@ -16,7 +16,7 @@ const isCompanyHub = computed(() => hubType.value === 'company');
|
|
|
16
16
|
<!-- Banner overlay slot (e.g., origin banner for federated hubs) -->
|
|
17
17
|
<slot name="banner-overlay" />
|
|
18
18
|
|
|
19
|
-
<div class="cpub-hub-banner" :style="
|
|
19
|
+
<div class="cpub-hub-banner" :style="bannerBgStyle(hub.bannerUrl)">
|
|
20
20
|
<template v-if="!hub.bannerUrl">
|
|
21
21
|
<div class="cpub-hub-banner-pattern"></div>
|
|
22
22
|
<div class="cpub-hub-banner-dots"></div>
|
|
@@ -30,6 +30,8 @@ export interface ContentSaveReturn {
|
|
|
30
30
|
handleSave: () => Promise<void>;
|
|
31
31
|
/** Validate, save, publish, and navigate */
|
|
32
32
|
handlePublish: (validate?: () => string[]) => Promise<string[]>;
|
|
33
|
+
/** Validate, save, and schedule for future publish using metadata.scheduledAt */
|
|
34
|
+
handleSchedule: (validate?: () => string[]) => Promise<string[]>;
|
|
33
35
|
/** Build a clean save body from current state */
|
|
34
36
|
buildSaveBody: () => Record<string, unknown>;
|
|
35
37
|
/** Schedule an autosave after the debounce delay */
|
|
@@ -81,6 +83,13 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
81
83
|
for (const key of Object.keys(body)) {
|
|
82
84
|
if (body[key] === '') body[key] = undefined;
|
|
83
85
|
}
|
|
86
|
+
// scheduledAt arrives from a datetime-local control as a bare local string;
|
|
87
|
+
// resolve it to an absolute UTC instant client-side so the server never
|
|
88
|
+
// reparses it in its own timezone (which would shift the stored time).
|
|
89
|
+
if (typeof body.scheduledAt === 'string' && body.scheduledAt) {
|
|
90
|
+
const d = new Date(body.scheduledAt);
|
|
91
|
+
body.scheduledAt = Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
92
|
+
}
|
|
84
93
|
return body;
|
|
85
94
|
}
|
|
86
95
|
|
|
@@ -225,6 +234,55 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
225
234
|
}
|
|
226
235
|
}
|
|
227
236
|
|
|
237
|
+
async function handleSchedule(validate?: () => string[]): Promise<string[]> {
|
|
238
|
+
if (saving.value || !opts.title.value) return ['Title is required'];
|
|
239
|
+
const raw = opts.metadata.value?.scheduledAt as string | undefined;
|
|
240
|
+
if (!raw) return ['Pick a date and time to schedule'];
|
|
241
|
+
const when = new Date(raw);
|
|
242
|
+
if (Number.isNaN(when.getTime()) || when.getTime() <= Date.now()) {
|
|
243
|
+
error.value = 'Scheduled time must be in the future.';
|
|
244
|
+
return ['Scheduled time must be in the future'];
|
|
245
|
+
}
|
|
246
|
+
if (validate) {
|
|
247
|
+
const errs = validate();
|
|
248
|
+
if (errs.length > 0) return errs;
|
|
249
|
+
}
|
|
250
|
+
cancelAutoSave();
|
|
251
|
+
saving.value = true;
|
|
252
|
+
error.value = '';
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const body = buildSaveBody();
|
|
256
|
+
let resultSlug = useRoute().params.slug as string;
|
|
257
|
+
|
|
258
|
+
if (opts.isNew.value || !opts.contentId.value) {
|
|
259
|
+
const result = await createDraft(body);
|
|
260
|
+
opts.contentId.value = result.id;
|
|
261
|
+
opts.isNew.value = false;
|
|
262
|
+
resultSlug = result.slug;
|
|
263
|
+
if (opts.onAfterSave) await opts.onAfterSave(result.id);
|
|
264
|
+
} else {
|
|
265
|
+
const updated = await $fetch<{ slug: string }>(`/api/content/${opts.contentId.value}`, { method: 'PUT', body });
|
|
266
|
+
if (updated?.slug) resultSlug = updated.slug;
|
|
267
|
+
if (opts.onAfterSave) await opts.onAfterSave(opts.contentId.value!);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Send an absolute instant: `raw` is a local "YYYY-MM-DDTHH:mm" value, so
|
|
271
|
+
// resolve it to UTC client-side rather than letting the server reparse it
|
|
272
|
+
// in its own timezone.
|
|
273
|
+
await $fetch(`/api/content/${opts.contentId.value}/schedule`, { method: 'POST', body: { scheduledAt: when.toISOString() } });
|
|
274
|
+
|
|
275
|
+
opts.isDirty.value = false;
|
|
276
|
+
await navigateTo(viewPath(opts.contentType.value, resultSlug));
|
|
277
|
+
return [];
|
|
278
|
+
} catch (err: unknown) {
|
|
279
|
+
error.value = opts.extractError(err);
|
|
280
|
+
return [];
|
|
281
|
+
} finally {
|
|
282
|
+
saving.value = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
228
286
|
function scheduleAutoSave(): void {
|
|
229
287
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
230
288
|
autoSaveTimer = setTimeout(async () => {
|
|
@@ -256,6 +314,7 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
256
314
|
silentSave,
|
|
257
315
|
handleSave,
|
|
258
316
|
handlePublish,
|
|
317
|
+
handleSchedule,
|
|
259
318
|
buildSaveBody,
|
|
260
319
|
scheduleAutoSave,
|
|
261
320
|
cancelAutoSave,
|
|
@@ -86,5 +86,14 @@ export function useFocusTrap(
|
|
|
86
86
|
onUnmounted(() => {
|
|
87
87
|
document.body.style.overflow = '';
|
|
88
88
|
document.removeEventListener('keydown', handleKeydown);
|
|
89
|
+
// Most consumers close by v-if-unmounting while still "open" (the isOpen
|
|
90
|
+
// getter never flips false), so the watch's else-branch never restores
|
|
91
|
+
// focus. Restore here too. `previousActive` is nulled by the else-branch
|
|
92
|
+
// when it does run, so this won't double-restore. Focusing a detached
|
|
93
|
+
// element is a harmless no-op.
|
|
94
|
+
if (previousActive) {
|
|
95
|
+
previousActive.focus();
|
|
96
|
+
previousActive = null;
|
|
97
|
+
}
|
|
89
98
|
});
|
|
90
99
|
}
|
package/layouts/default.vue
CHANGED
|
@@ -256,7 +256,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
256
256
|
<NuxtLink v-if="docs" to="/docs" class="cpub-footer-link">Docs</NuxtLink>
|
|
257
257
|
<NuxtLink to="/privacy" class="cpub-footer-link">Privacy Policy</NuxtLink>
|
|
258
258
|
<NuxtLink to="/cookies" class="cpub-footer-link">Cookie Policy</NuxtLink>
|
|
259
|
-
<NuxtLink to="/terms" class="cpub-footer-link">Terms of
|
|
259
|
+
<NuxtLink to="/terms" class="cpub-footer-link">Terms & Code of Conduct</NuxtLink>
|
|
260
260
|
<a href="/feed.xml" class="cpub-footer-link">RSS Feed</a>
|
|
261
261
|
</nav>
|
|
262
262
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -51,19 +51,20 @@
|
|
|
51
51
|
"sharp": "^0.34.5",
|
|
52
52
|
"shiki": "^4.0.2",
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
|
+
"vue-advanced-cropper": "^2.8.9",
|
|
54
55
|
"vue-router": "^4.3.0",
|
|
55
56
|
"zod": "^4.3.6",
|
|
56
57
|
"@commonpub/auth": "0.8.0",
|
|
57
|
-
"@commonpub/config": "0.22.1",
|
|
58
58
|
"@commonpub/docs": "0.6.3",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/learning": "0.5.2",
|
|
61
|
-
"@commonpub/schema": "0.43.0",
|
|
59
|
+
"@commonpub/config": "0.22.1",
|
|
62
60
|
"@commonpub/protocol": "0.13.0",
|
|
63
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
62
|
+
"@commonpub/theme-studio": "0.6.1",
|
|
63
|
+
"@commonpub/schema": "0.44.0",
|
|
64
|
+
"@commonpub/server": "2.88.0",
|
|
64
65
|
"@commonpub/ui": "0.13.1",
|
|
65
66
|
"@commonpub/explainer": "0.7.15",
|
|
66
|
-
"@commonpub/
|
|
67
|
+
"@commonpub/editor": "0.7.12"
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
|
69
70
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -94,9 +94,16 @@ async function rejectRequest(id: string): Promise<void> {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// Registry directory (Phase 4)
|
|
98
|
-
|
|
97
|
+
// Registry directory (Phase 4). When this instance ACTS AS a registry, show the
|
|
98
|
+
// local directory (with owner hide/block controls). When it only ANNOUNCES to a
|
|
99
|
+
// registry, pull that registry's public directory so the operator can still
|
|
100
|
+
// discover every peer registered there (read-only). id/status are absent on the
|
|
101
|
+
// remote (public) view.
|
|
102
|
+
type RegistryRow = { id?: string; domain: string; actorUri: string; name: string | null; description: string | null; userCount: number; activeMonthCount: number; localPostCount: number; softwareName: string | null; softwareVersion: string | null; status?: string; lastPingAt: string | null; online: boolean };
|
|
99
103
|
const registrySearch = ref('');
|
|
104
|
+
const registryTabAvailable = computed(() => actAsRegistry.value || announceToRegistry.value);
|
|
105
|
+
const showRemoteDirectory = computed(() => !actAsRegistry.value && announceToRegistry.value);
|
|
106
|
+
|
|
100
107
|
const { data: registryData, refresh: refreshRegistry } = await useFetch<{ instances: RegistryRow[]; total: number }>(
|
|
101
108
|
'/api/admin/registry/instances',
|
|
102
109
|
{
|
|
@@ -106,9 +113,31 @@ const { data: registryData, refresh: refreshRegistry } = await useFetch<{ instan
|
|
|
106
113
|
},
|
|
107
114
|
);
|
|
108
115
|
|
|
116
|
+
const { data: registryDirData, refresh: refreshRegistryDir } = await useFetch<{ instances: RegistryRow[]; total: number; registryUrl: string | null }>(
|
|
117
|
+
'/api/admin/registry/directory',
|
|
118
|
+
{
|
|
119
|
+
query: computed(() => ({ search: registrySearch.value || undefined })),
|
|
120
|
+
default: () => ({ instances: [], total: 0, registryUrl: null }),
|
|
121
|
+
immediate: showRemoteDirectory.value,
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const registryInstances = computed<RegistryRow[]>(() =>
|
|
126
|
+
actAsRegistry.value ? (registryData.value?.instances ?? []) : (registryDirData.value?.instances ?? []),
|
|
127
|
+
);
|
|
128
|
+
const registryLabel = computed<string | null>(() => {
|
|
129
|
+
if (!announceToRegistry.value) return null;
|
|
130
|
+
const url = registryDirData.value?.registryUrl;
|
|
131
|
+
if (showRemoteDirectory.value && url) {
|
|
132
|
+
try { return new URL(url).hostname; } catch { return 'your configured registry'; }
|
|
133
|
+
}
|
|
134
|
+
return 'your configured registry';
|
|
135
|
+
});
|
|
136
|
+
|
|
109
137
|
function onRegistrySearch(value: string): void {
|
|
110
138
|
registrySearch.value = value;
|
|
111
|
-
void refreshRegistry();
|
|
139
|
+
if (actAsRegistry.value) void refreshRegistry();
|
|
140
|
+
else void refreshRegistryDir();
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
// Mirror creation
|
|
@@ -341,7 +370,7 @@ async function refederate(): Promise<void> {
|
|
|
341
370
|
<div class="cpub-fed-tabs">
|
|
342
371
|
<button :class="{ active: activeTab === 'activity' }" @click="activeTab = 'activity'">Activity</button>
|
|
343
372
|
<button :class="{ active: activeTab === 'mirrors' }" @click="activeTab = 'mirrors'">Mirrors</button>
|
|
344
|
-
<button v-if="
|
|
373
|
+
<button v-if="registryTabAvailable" :class="{ active: activeTab === 'registry' }" @click="activeTab = 'registry'">Registry</button>
|
|
345
374
|
<button :class="{ active: activeTab === 'clients' }" @click="activeTab = 'clients'">OAuth Clients</button>
|
|
346
375
|
<button :class="{ active: activeTab === 'trusted' }" @click="activeTab = 'trusted'">Trusted Instances</button>
|
|
347
376
|
<button :class="{ active: activeTab === 'tools' }" @click="activeTab = 'tools'">Tools</button>
|
|
@@ -514,10 +543,11 @@ async function refederate(): Promise<void> {
|
|
|
514
543
|
</div>
|
|
515
544
|
|
|
516
545
|
<!-- Registry Tab -->
|
|
517
|
-
<div v-if="activeTab === 'registry' &&
|
|
546
|
+
<div v-if="activeTab === 'registry' && registryTabAvailable">
|
|
518
547
|
<RegistryDirectory
|
|
519
|
-
:instances="
|
|
520
|
-
:
|
|
548
|
+
:instances="registryInstances"
|
|
549
|
+
:readonly-mode="showRemoteDirectory"
|
|
550
|
+
:announcing-to="registryLabel"
|
|
521
551
|
@changed="refreshMirrors"
|
|
522
552
|
@search="onRegistrySearch"
|
|
523
553
|
/>
|
package/pages/hubs/index.vue
CHANGED
|
@@ -39,7 +39,7 @@ function hubLink(hub: Record<string, unknown>): string {
|
|
|
39
39
|
:to="hubLink(hub as Record<string, unknown>)"
|
|
40
40
|
class="cpub-hub-card"
|
|
41
41
|
>
|
|
42
|
-
<div class="cpub-hub-card-banner" :style="hub.bannerUrl
|
|
42
|
+
<div class="cpub-hub-card-banner" :style="bannerBgStyle(hub.bannerUrl, {})">
|
|
43
43
|
<div class="cpub-hub-card-icon">
|
|
44
44
|
<img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" class="cpub-hub-card-avatar" />
|
|
45
45
|
<i v-else :class="hub.hubType === 'company' ? 'fa-solid fa-building' : hub.hubType === 'product' ? 'fa-solid fa-microchip' : 'fa-solid fa-users'"></i>
|
package/pages/terms.vue
CHANGED
|
@@ -1,91 +1,157 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const config = useRuntimeConfig();
|
|
3
|
+
// The instance's own brand name + domain, from config. The terms template was
|
|
4
|
+
// authored against the deveco.io reference host; here we substitute this
|
|
5
|
+
// instance's identity. On the canonical CommonPub instance we collapse
|
|
6
|
+
// "CommonPub and <instance>" to just "CommonPub" (it would otherwise be redundant).
|
|
7
|
+
const instanceName = computed(() => (config.public.siteName as string) || 'CommonPub');
|
|
8
|
+
const instanceDomain = computed(() => (config.public.domain as string) || 'this instance');
|
|
9
|
+
const isCanonical = computed(
|
|
10
|
+
() => instanceName.value.trim().toLowerCase() === 'commonpub' || instanceDomain.value === 'commonpub.io',
|
|
11
|
+
);
|
|
12
|
+
/** "CommonPub and <name>", or just "CommonPub" on the canonical instance. */
|
|
13
|
+
const brandAnd = computed(() => (isCanonical.value ? 'CommonPub' : `CommonPub and ${instanceName.value}`));
|
|
14
|
+
/** "CommonPub and <domain>" subtitle form. */
|
|
15
|
+
const brandAndDomain = computed(() => (isCanonical.value ? 'CommonPub' : `CommonPub and ${instanceDomain.value}`));
|
|
16
|
+
/** "CommonPub or <name>". */
|
|
17
|
+
const brandOr = computed(() => (isCanonical.value ? 'CommonPub' : `CommonPub or ${instanceName.value}`));
|
|
18
|
+
/** verb suffix so "CommonPub and X exist" becomes "CommonPub exists" when canonical. */
|
|
19
|
+
const s = computed(() => (isCanonical.value ? 's' : ''));
|
|
20
|
+
const host = instanceDomain;
|
|
21
|
+
|
|
2
22
|
useSeoMeta({
|
|
3
|
-
title: `Terms of
|
|
4
|
-
description: '
|
|
23
|
+
title: () => `Community Terms and Code of Conduct, ${instanceName.value}`,
|
|
24
|
+
description: 'Community terms and code of conduct for this CommonPub instance.',
|
|
5
25
|
});
|
|
6
|
-
|
|
7
|
-
const siteName = useSiteName();
|
|
8
26
|
</script>
|
|
9
27
|
|
|
10
28
|
<template>
|
|
11
29
|
<div class="cpub-legal">
|
|
12
30
|
<div class="cpub-legal-header">
|
|
13
|
-
<h1 class="cpub-legal-title">Terms of
|
|
14
|
-
<p class="cpub-legal-
|
|
31
|
+
<h1 class="cpub-legal-title">Community Terms and Code of Conduct</h1>
|
|
32
|
+
<p class="cpub-legal-subtitle">{{ brandAndDomain }}</p>
|
|
33
|
+
<p class="cpub-legal-updated">Last updated: March 2026</p>
|
|
15
34
|
</div>
|
|
16
35
|
|
|
17
36
|
<div class="cpub-legal-body">
|
|
37
|
+
<p>Welcome to {{ brandAnd }}. CommonPub is an open source, federated publishing platform for maker and hardware communities. {{ host }} is where we host it, where people publish projects, build logs, and guides, and where they talk with each other about the work. These terms cover how you use {{ host }} and the community spaces around it.</p>
|
|
38
|
+
<p>The CommonPub software itself is open source under the AGPL-3.0 license. You are free to read it, change it, and run your own instance. If you self-host, the parts of these terms about our hosted service do not apply to you, but the spirit of the code of conduct and the values below is something we hope you carry forward.</p>
|
|
39
|
+
<p>By using {{ host }}, you agree to these terms and to the code of conduct here. If you do not agree with them, please do not use the service.</p>
|
|
40
|
+
|
|
18
41
|
<section class="cpub-legal-section">
|
|
19
|
-
<h2>1.
|
|
20
|
-
<p>
|
|
42
|
+
<h2>1. What this covers</h2>
|
|
43
|
+
<p>These terms apply to the {{ host }} hosted service and the community spaces we run, including project pages, discussion threads, and our chat channels. They also cover interactions you start here that reach other servers.</p>
|
|
44
|
+
<p>CommonPub speaks ActivityPub, so the network is federated. When you post in public, your content can be sent to other servers in the network and to the people who follow you from those servers. Once something is public and federated, copies can live on servers we do not control, and we cannot pull those copies back. Keep that in mind when you decide what to publish.</p>
|
|
21
45
|
</section>
|
|
22
46
|
|
|
23
47
|
<section class="cpub-legal-section">
|
|
24
|
-
<h2>2.
|
|
48
|
+
<h2>2. Using {{ brandAnd }}</h2>
|
|
49
|
+
<p>You are welcome to:</p>
|
|
25
50
|
<ul>
|
|
26
|
-
<li>
|
|
27
|
-
<li>
|
|
28
|
-
<li>
|
|
29
|
-
<li>
|
|
51
|
+
<li>Publish projects, build logs, guides, and writeups for personal, educational, and commercial purposes.</li>
|
|
52
|
+
<li>Run workshops, classes, and training programs that use {{ brandAnd }}.</li>
|
|
53
|
+
<li>Share tutorials, screenshots, and demos of your work anywhere you like.</li>
|
|
54
|
+
<li>Follow, reply to, and collaborate with people on other federated servers.</li>
|
|
55
|
+
<li>Build on the open source code and run your own instance.</li>
|
|
30
56
|
</ul>
|
|
57
|
+
<p>If you share work that features {{ brandOr }}, a credit like "Published on {{ host }}" or a link back is appreciated. It is not required.</p>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="cpub-legal-section">
|
|
61
|
+
<h2>3. Your content and ownership</h2>
|
|
62
|
+
<p>You own your work. We claim no ownership over your projects, posts, files, designs, or anything else you make or upload. We do not sell your content, and we do not train models on it.</p>
|
|
63
|
+
<p>By posting, you give us a limited license to store, display, and federate your content for the single purpose of running the service. That means holding it in our infrastructure, showing it in the interface, and sending your public posts to other servers and followers in the network. The license ends when you delete the content or your account, except for federated copies already sent to servers we do not control.</p>
|
|
64
|
+
<p>You are responsible for having the rights to what you post, and for getting consent when your work involves other people's data, faces, or voices. We encourage you to license your projects openly so others can learn from them and build on them, but that choice is always yours.</p>
|
|
31
65
|
</section>
|
|
32
66
|
|
|
33
67
|
<section class="cpub-legal-section">
|
|
34
|
-
<h2>
|
|
35
|
-
<p>
|
|
68
|
+
<h2>4. An open community</h2>
|
|
69
|
+
<p>CommonPub exists so that the people who build things can share them and own them. We want you to:</p>
|
|
36
70
|
<ul>
|
|
37
|
-
<li>
|
|
38
|
-
<li>
|
|
39
|
-
<li>
|
|
40
|
-
<li>
|
|
71
|
+
<li>Publish your work freely, here and on any other platform.</li>
|
|
72
|
+
<li>Write tutorials and teach what you know.</li>
|
|
73
|
+
<li>Document the messy parts of a project, the dead ends and the rebuilds, not just the clean result.</li>
|
|
74
|
+
<li>Use the platform in classrooms, hackerspaces, and workshops.</li>
|
|
75
|
+
<li>Use it for paid and commercial work.</li>
|
|
76
|
+
<li>Contribute improvements back to the project under our contributor model, with a DCO sign-off, if you want to make the software better.</li>
|
|
41
77
|
</ul>
|
|
42
78
|
</section>
|
|
43
79
|
|
|
44
80
|
<section class="cpub-legal-section">
|
|
45
|
-
<h2>
|
|
81
|
+
<h2>5. Code of conduct</h2>
|
|
82
|
+
<p>We want {{ brandAnd }} to be a place where people treat each other with respect.</p>
|
|
83
|
+
<p>Everyone is welcome here regardless of race, ethnicity, national origin, religion, gender, gender identity, sexual orientation, disability, age, or experience level. A beginner asking a basic question deserves the same respect as a veteran shipping their tenth board.</p>
|
|
84
|
+
<p>You agree to:</p>
|
|
85
|
+
<ul>
|
|
86
|
+
<li>Treat other people with respect and assume good faith.</li>
|
|
87
|
+
<li>Keep your criticism aimed at the work, not at the person who made it.</li>
|
|
88
|
+
<li>Respect other people's privacy and their consent.</li>
|
|
89
|
+
</ul>
|
|
46
90
|
<p>You agree not to:</p>
|
|
47
91
|
<ul>
|
|
48
|
-
<li>
|
|
49
|
-
<li>
|
|
50
|
-
<li>
|
|
51
|
-
<li>
|
|
52
|
-
<li>Scrape, crawl, or automatically collect data from the platform beyond what public APIs allow</li>
|
|
53
|
-
<li>Impersonate another person or entity</li>
|
|
54
|
-
<li>Use the platform for commercial advertising without permission</li>
|
|
92
|
+
<li>Harass, threaten, demean, or discriminate against anyone.</li>
|
|
93
|
+
<li>Post hate speech or content that attacks people for who they are.</li>
|
|
94
|
+
<li>Share someone's private information without their consent.</li>
|
|
95
|
+
<li>Impersonate other people or misrepresent who built something.</li>
|
|
55
96
|
</ul>
|
|
97
|
+
<p>How we enforce this: where we reasonably can, we reach out first and try to sort the problem out. Enforcement is proportionate, from a quiet word, to a warning, to suspension, to removal for serious or repeated harm. If you think we got it wrong, you can appeal. These rules apply in our community spaces and to federated interactions that start here.</p>
|
|
56
98
|
</section>
|
|
57
99
|
|
|
58
100
|
<section class="cpub-legal-section">
|
|
59
|
-
<h2>
|
|
60
|
-
<p>
|
|
101
|
+
<h2>6. Acceptable use</h2>
|
|
102
|
+
<p>To keep the platform working and safe for everyone, you agree not to:</p>
|
|
61
103
|
<ul>
|
|
62
|
-
<li>
|
|
63
|
-
<li>
|
|
64
|
-
<li>
|
|
104
|
+
<li>Disrupt, damage, or try to gain unauthorized access to our systems.</li>
|
|
105
|
+
<li>Upload or share content that is illegal or that harms others.</li>
|
|
106
|
+
<li>Infringe anyone else's intellectual property.</li>
|
|
107
|
+
<li>Write, host, or distribute malware.</li>
|
|
108
|
+
<li>Build surveillance of individuals or populations, or track people without their knowledge and consent.</li>
|
|
109
|
+
<li>Collect personal or biometric data without clear consent and real protection.</li>
|
|
65
110
|
</ul>
|
|
66
|
-
<p>We
|
|
111
|
+
<p>We can suspend or end accounts that break these rules.</p>
|
|
112
|
+
</section>
|
|
113
|
+
|
|
114
|
+
<section class="cpub-legal-section">
|
|
115
|
+
<h2>7. What we will not work with</h2>
|
|
116
|
+
<p>{{ brandAnd }} exist{{ s }} to help communities. We are clear about the work we will not be part of. These limits apply to us, to the partners and funders we take on, and to how the platform may be used.</p>
|
|
117
|
+
<ul>
|
|
118
|
+
<li>We do not work with defense contractors, and we do not support military or defense applications of our tools. We will not partner with, take funding from, or provide paid services to organizations whose purpose is building or deploying weapons or military systems.</li>
|
|
119
|
+
<li>We do not work with companies that materially support the Israeli military (IDF). This is about complicity in unlawful military operations and violations of international humanitarian law. It follows the call of the BDS movement and the No Tech for Apartheid campaign.</li>
|
|
120
|
+
<li>We will not knowingly platform or provide services for lethal or offensive weapons and targeting systems, for mass or targeted surveillance of people, or for any use meant to harm a community rather than help it.</li>
|
|
121
|
+
<li>We reserve the right to refuse or end service to any organization that uses our tools for these purposes, or that materially supports war crimes.</li>
|
|
122
|
+
</ul>
|
|
123
|
+
<p>To be clear, these limits are about conduct, about military and defense use, and about material support for war crimes. They are not about anyone's nationality, ethnicity, or faith. Individual makers are always welcome here no matter where they are from or what they believe, and section 5 protects them.</p>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<section class="cpub-legal-section">
|
|
127
|
+
<h2>8. Statement of values</h2>
|
|
128
|
+
<p>We are makers, and we believe the tools people build should belong to everyone and should be used to help, not to harm.</p>
|
|
129
|
+
<p>These values shape who we work with and what we build, and they are not up for negotiation.</p>
|
|
67
130
|
</section>
|
|
68
131
|
|
|
69
132
|
<section class="cpub-legal-section">
|
|
70
|
-
<h2>
|
|
71
|
-
<p>
|
|
72
|
-
<p>
|
|
133
|
+
<h2>9. No warranty and limits on liability</h2>
|
|
134
|
+
<p>{{ host }} is provided as is, without warranties of any kind. We work to keep it reliable, but we cannot promise it will never go down or never lose data.</p>
|
|
135
|
+
<p>To the extent the law allows, we are not liable for indirect, incidental, or consequential damages, including loss of data, projects, or business opportunities, even if we were told such loss was possible.</p>
|
|
136
|
+
<p>Because CommonPub is open source and federated, you can export your work and run your own instance at any time. We still recommend keeping your own backups of anything you cannot afford to lose.</p>
|
|
73
137
|
</section>
|
|
74
138
|
|
|
75
139
|
<section class="cpub-legal-section">
|
|
76
|
-
<h2>
|
|
77
|
-
<p>
|
|
140
|
+
<h2>10. Changes to these terms</h2>
|
|
141
|
+
<p>We may update these terms from time to time. We will tell you about material changes through the service or by email. Using {{ host }} after a change is posted means you accept the updated terms.</p>
|
|
78
142
|
</section>
|
|
79
143
|
|
|
80
144
|
<section class="cpub-legal-section">
|
|
81
|
-
<h2>
|
|
82
|
-
<p>
|
|
145
|
+
<h2>11. Governing law</h2>
|
|
146
|
+
<p>These terms are governed by and read in line with applicable law, and any disputes will be handled through the appropriate legal channels.</p>
|
|
83
147
|
</section>
|
|
84
148
|
|
|
85
149
|
<section class="cpub-legal-section">
|
|
86
|
-
<h2>
|
|
87
|
-
<p>
|
|
150
|
+
<h2>12. Contact</h2>
|
|
151
|
+
<p>Questions about these terms or the code of conduct can come to us through {{ host }} or our community channels, including Discord and GitHub.</p>
|
|
88
152
|
</section>
|
|
153
|
+
|
|
154
|
+
<p class="cpub-legal-closer">Made by makers. Built to help, not to harm.</p>
|
|
89
155
|
</div>
|
|
90
156
|
</div>
|
|
91
157
|
</template>
|
|
@@ -107,6 +173,13 @@ const siteName = useSiteName();
|
|
|
107
173
|
margin-bottom: 8px;
|
|
108
174
|
}
|
|
109
175
|
|
|
176
|
+
.cpub-legal-subtitle {
|
|
177
|
+
font-size: 14px;
|
|
178
|
+
color: var(--text-dim);
|
|
179
|
+
font-family: var(--font-mono);
|
|
180
|
+
margin-bottom: 8px;
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
.cpub-legal-updated {
|
|
111
184
|
font-size: 12px;
|
|
112
185
|
color: var(--text-faint);
|
|
@@ -119,6 +192,19 @@ const siteName = useSiteName();
|
|
|
119
192
|
gap: 32px;
|
|
120
193
|
}
|
|
121
194
|
|
|
195
|
+
.cpub-legal-body > p {
|
|
196
|
+
font-size: 14px;
|
|
197
|
+
line-height: 1.7;
|
|
198
|
+
color: var(--text-dim);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.cpub-legal-closer {
|
|
202
|
+
font-style: italic;
|
|
203
|
+
color: var(--text);
|
|
204
|
+
border-top: var(--border-width-default) solid var(--border);
|
|
205
|
+
padding-top: 24px;
|
|
206
|
+
}
|
|
207
|
+
|
|
122
208
|
.cpub-legal-section h2 {
|
|
123
209
|
font-size: 16px;
|
|
124
210
|
font-weight: 600;
|
|
@@ -95,6 +95,7 @@ const {
|
|
|
95
95
|
autoSaveStatus,
|
|
96
96
|
silentSave,
|
|
97
97
|
handlePublish: doPublish,
|
|
98
|
+
handleSchedule: doSchedule,
|
|
98
99
|
buildSaveBody,
|
|
99
100
|
cancelAutoSave,
|
|
100
101
|
initAutoSave,
|
|
@@ -182,10 +183,20 @@ if (!isNew.value) {
|
|
|
182
183
|
series: (d.series as string) || '',
|
|
183
184
|
category: (d.category as string) || '',
|
|
184
185
|
subtitle: (d.subtitle as string) || '',
|
|
186
|
+
scheduledAt: d.scheduledAt ? toLocalDatetimeInput(d.scheduledAt as string) : '',
|
|
185
187
|
};
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
// Convert a stored ISO timestamp to the local "YYYY-MM-DDTHH:mm" value that an
|
|
192
|
+
// <input type="datetime-local"> expects, so an existing schedule shows correctly.
|
|
193
|
+
function toLocalDatetimeInput(iso: string): string {
|
|
194
|
+
const dt = new Date(iso);
|
|
195
|
+
if (Number.isNaN(dt.getTime())) return '';
|
|
196
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
197
|
+
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
// --- Auto-generate slug from title ---
|
|
190
201
|
const slugManuallyEdited = ref(false);
|
|
191
202
|
function slugify(text: string): string {
|
|
@@ -294,6 +305,13 @@ async function handlePublish(): Promise<void> {
|
|
|
294
305
|
}
|
|
295
306
|
}
|
|
296
307
|
|
|
308
|
+
// --- Schedule (deferred publish) ---
|
|
309
|
+
// metadata.scheduledAt is set by the editor's schedule control (e.g. BlogEditor).
|
|
310
|
+
const canSchedule = computed(() => !!(metadata.value.scheduledAt as string | undefined));
|
|
311
|
+
async function handleSchedule(): Promise<void> {
|
|
312
|
+
await doSchedule(validate);
|
|
313
|
+
}
|
|
314
|
+
|
|
297
315
|
// --- Preview mode ---
|
|
298
316
|
function enterPreview(): void {
|
|
299
317
|
mode.value = 'preview';
|
|
@@ -473,6 +491,9 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
|
473
491
|
<button class="cpub-topbar-btn" :disabled="saving || !title" @click="silentSave">
|
|
474
492
|
{{ saving ? 'Saving...' : 'Save Draft' }}
|
|
475
493
|
</button>
|
|
494
|
+
<button v-if="canSchedule" class="cpub-topbar-btn" :disabled="saving || !title" @click="handleSchedule">
|
|
495
|
+
Schedule
|
|
496
|
+
</button>
|
|
476
497
|
<button class="cpub-topbar-btn cpub-topbar-btn-primary" :disabled="saving || !title" @click="handlePublish">
|
|
477
498
|
Publish
|
|
478
499
|
</button>
|
|
@@ -33,6 +33,7 @@ export default defineEventHandler(async (event) => {
|
|
|
33
33
|
name: actor.name ?? actor.preferredUsername ?? remoteSlug,
|
|
34
34
|
description: actor.summary ?? undefined,
|
|
35
35
|
iconUrl: actor.icon?.url ?? undefined,
|
|
36
|
+
bannerUrl: actor.image?.url ?? undefined,
|
|
36
37
|
url: `https://${domain}/hubs/${remoteSlug}`,
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { safeFetch } from '@commonpub/protocol';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/admin/registry/directory (Phase 4 — peer discovery)
|
|
5
|
+
*
|
|
6
|
+
* For an instance that ANNOUNCES to a registry but is not itself a registry:
|
|
7
|
+
* fetch the configured registry's PUBLIC directory (`GET /api/registry/instances`)
|
|
8
|
+
* so the operator can see every peer registered there. Without this, only the
|
|
9
|
+
* registry server itself could see the directory — pinging instances had no way
|
|
10
|
+
* to discover each other.
|
|
11
|
+
*
|
|
12
|
+
* Read-only (no hide/block — those are registry-owner controls). SSRF-guarded via
|
|
13
|
+
* `safeFetch`. Admin only, gated on `features.announceToRegistry`.
|
|
14
|
+
*/
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requirePermission(event, 'federation.manage');
|
|
17
|
+
requireFeature('announceToRegistry');
|
|
18
|
+
|
|
19
|
+
const config = useConfig();
|
|
20
|
+
const registryUrl = config.federation?.registryUrl;
|
|
21
|
+
if (!registryUrl) {
|
|
22
|
+
return { instances: [], total: 0, registryUrl: null, self: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let regHost: string;
|
|
26
|
+
try {
|
|
27
|
+
regHost = new URL(registryUrl).hostname;
|
|
28
|
+
} catch {
|
|
29
|
+
throw createError({ statusCode: 500, statusMessage: 'Invalid registryUrl config' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// If we ping ourselves (this instance IS its own registry), there's no remote
|
|
33
|
+
// directory to pull — the local actAsRegistry view already covers it.
|
|
34
|
+
if (regHost === config.instance.domain) {
|
|
35
|
+
return { instances: [], total: 0, registryUrl, self: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const q = getQuery(event);
|
|
39
|
+
const search = typeof q.search === 'string' ? q.search : '';
|
|
40
|
+
const target = new URL('/api/registry/instances', registryUrl);
|
|
41
|
+
if (search) target.searchParams.set('search', search);
|
|
42
|
+
target.searchParams.set('limit', '50');
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const { html } = await safeFetch(target.toString(), { timeoutMs: 10_000 });
|
|
46
|
+
const json = JSON.parse(html) as { instances?: unknown[]; total?: number };
|
|
47
|
+
return {
|
|
48
|
+
instances: Array.isArray(json.instances) ? json.instances : [],
|
|
49
|
+
total: typeof json.total === 'number' ? json.total : 0,
|
|
50
|
+
registryUrl,
|
|
51
|
+
self: false,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
throw createError({ statusCode: 502, statusMessage: 'Could not reach the registry directory' });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { scheduleContent } from '@commonpub/server';
|
|
2
|
+
import type { ContentDetail } from '@commonpub/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const scheduleBodySchema = z.object({ scheduledAt: z.coerce.date() });
|
|
6
|
+
|
|
7
|
+
export default defineEventHandler(async (event): Promise<ContentDetail> => {
|
|
8
|
+
const user = requireAuth(event);
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
11
|
+
const { scheduledAt } = await parseBody(event, scheduleBodySchema);
|
|
12
|
+
|
|
13
|
+
if (scheduledAt.getTime() <= Date.now()) {
|
|
14
|
+
throw createError({ statusCode: 400, statusMessage: 'Scheduled time must be in the future' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const content = await scheduleContent(db, id, user.id, scheduledAt);
|
|
18
|
+
if (!content) {
|
|
19
|
+
throw createError({ statusCode: 404, statusMessage: 'Content not found, not yours, or already published' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return content;
|
|
23
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled-publishing worker.
|
|
3
|
+
* Runs on an interval and publishes any content whose `scheduledAt` time has
|
|
4
|
+
* passed (status='scheduled'). Mirrors the federation-delivery worker: an
|
|
5
|
+
* in-process setInterval started after a short stagger, cleaned up on close.
|
|
6
|
+
*
|
|
7
|
+
* The claim is a single atomic `UPDATE ... RETURNING` inside publishDueScheduled,
|
|
8
|
+
* so this is safe to run on multiple replicas without double-publishing.
|
|
9
|
+
*/
|
|
10
|
+
import { publishDueScheduled } from '@commonpub/server';
|
|
11
|
+
|
|
12
|
+
export default defineNitroPlugin((nitro) => {
|
|
13
|
+
if (process.env.NODE_ENV === 'test') return;
|
|
14
|
+
|
|
15
|
+
// Sweep cadence. One minute keeps publish latency low without meaningful load
|
|
16
|
+
// (a single indexed UPDATE per tick).
|
|
17
|
+
const INTERVAL_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
|
|
21
|
+
const startupTimer = setTimeout(() => {
|
|
22
|
+
try {
|
|
23
|
+
console.log(`[scheduled-publishing] worker started (interval: ${INTERVAL_MS}ms)`);
|
|
24
|
+
runSweep();
|
|
25
|
+
interval = setInterval(runSweep, INTERVAL_MS);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error('[scheduled-publishing] worker failed to start:', err instanceof Error ? err.message : err);
|
|
28
|
+
}
|
|
29
|
+
}, 12_000);
|
|
30
|
+
|
|
31
|
+
async function runSweep() {
|
|
32
|
+
try {
|
|
33
|
+
const db = useDB();
|
|
34
|
+
const config = useConfig();
|
|
35
|
+
const published = await publishDueScheduled(db, config);
|
|
36
|
+
if (published > 0) {
|
|
37
|
+
console.log(`[scheduled-publishing] published ${published} scheduled item(s)`);
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('[scheduled-publishing] sweep error:', err instanceof Error ? err.message : err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
nitro.hooks.hook('close', () => {
|
|
45
|
+
clearTimeout(startupTimer);
|
|
46
|
+
if (interval) {
|
|
47
|
+
clearInterval(interval);
|
|
48
|
+
interval = null;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a CSS `background-image` style from a possibly-untrusted URL — e.g. a
|
|
3
|
+
* federated hub's banner, which is now writable via inbound ActivityPub.
|
|
4
|
+
*
|
|
5
|
+
* Returns `{}` unless the URL is http(s). The URL is percent-encoded (so a `"`
|
|
6
|
+
* or backslash can't terminate the quoted string) and wrapped in double quotes,
|
|
7
|
+
* so a crafted value cannot break out of the `url(...)` context and inject CSS.
|
|
8
|
+
*/
|
|
9
|
+
export function bannerBgStyle(
|
|
10
|
+
url: string | null | undefined,
|
|
11
|
+
extra: Record<string, string> = { backgroundSize: 'cover', backgroundPosition: 'center' },
|
|
12
|
+
): Record<string, string> {
|
|
13
|
+
if (!url || !/^https?:\/\//i.test(url)) return {};
|
|
14
|
+
const safe = encodeURI(url).replace(/["\\]/g, '');
|
|
15
|
+
return { backgroundImage: `url("${safe}")`, ...extra };
|
|
16
|
+
}
|