@commonpub/layer 0.76.0 → 0.79.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.
@@ -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: string;
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: string;
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 busyId = ref<string | null>(null);
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
- busyId.value = row.id;
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
- busyId.value = null;
50
+ busyKey.value = null;
44
51
  }
45
52
  }
46
53
 
47
54
  async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocked'): Promise<void> {
48
- busyId.value = row.id;
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
- busyId.value = null;
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). Hide or block
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.id" class="cpub-fed-activity-row">
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="busyId === i.id" @click="mirror(i, 'pull')">Mirror</button>
94
- <button class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="mirror(i, 'push')">Request mirror</button>
95
- <button v-if="i.status === 'active'" class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="setStatus(i, 'hidden')">Hide</button>
96
- <button v-else-if="i.status === 'hidden'" class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="setStatus(i, 'active')">Unhide</button>
97
- <button class="cpub-fed-btn-sm cpub-fed-btn-danger" :disabled="busyId === i.id" @click="setStatus(i, i.status === 'blocked' ? 'active' : 'blocked')">
98
- {{ i.status === 'blocked' ? 'Unblock' : 'Block' }}
99
- </button>
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="hub.bannerUrl ? { backgroundImage: `url(${hub.bannerUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}">
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,
@@ -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 Service</NuxtLink>
259
+ <NuxtLink to="/terms" class="cpub-footer-link">Terms &amp; 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.76.0",
3
+ "version": "0.79.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,16 +54,16 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/config": "0.22.1",
58
57
  "@commonpub/docs": "0.6.3",
59
- "@commonpub/editor": "0.7.12",
58
+ "@commonpub/explainer": "0.7.15",
60
59
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/schema": "0.43.0",
60
+ "@commonpub/editor": "0.7.12",
61
+ "@commonpub/schema": "0.44.0",
62
+ "@commonpub/server": "2.88.0",
62
63
  "@commonpub/protocol": "0.13.0",
63
- "@commonpub/server": "2.86.0",
64
64
  "@commonpub/ui": "0.13.1",
65
- "@commonpub/explainer": "0.7.15",
66
- "@commonpub/theme-studio": "0.6.1"
65
+ "@commonpub/theme-studio": "0.6.1",
66
+ "@commonpub/config": "0.22.1"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@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) only fetched when this instance acts as a registry.
98
- 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 };
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="actAsRegistry" :class="{ active: activeTab === 'registry' }" @click="activeTab = 'registry'">Registry</button>
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' && actAsRegistry">
546
+ <div v-if="activeTab === 'registry' && registryTabAvailable">
518
547
  <RegistryDirectory
519
- :instances="registryData?.instances ?? []"
520
- :announcing-to="announceToRegistry ? 'your configured registry' : null"
548
+ :instances="registryInstances"
549
+ :readonly-mode="showRemoteDirectory"
550
+ :announcing-to="registryLabel"
521
551
  @changed="refreshMirrors"
522
552
  @search="onRegistrySearch"
523
553
  />
@@ -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 ? { backgroundImage: `url(${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 Service, ${useSiteName()}`,
4
- description: 'Terms and conditions for using this platform.',
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 Service</h1>
14
- <p class="cpub-legal-updated">Last updated: April 2026</p>
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. Acceptance</h2>
20
- <p>By creating an account or using {{ siteName }}, you agree to these Terms of Service and our <NuxtLink to="/privacy">Privacy Policy</NuxtLink>. If you do not agree, do not use the service.</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. Your Account</h2>
48
+ <h2>2. Using {{ brandAnd }}</h2>
49
+ <p>You are welcome to:</p>
25
50
  <ul>
26
- <li>You must provide accurate information when creating an account.</li>
27
- <li>You are responsible for keeping your login credentials secure.</li>
28
- <li>You must be at least 16 years old to create an account (or the minimum age required by your jurisdiction).</li>
29
- <li>One person, one account. Automated or bot accounts require prior approval from the instance administrator.</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>3. Your Content</h2>
35
- <p>You retain ownership of content you create on {{ siteName }}. By publishing content, you grant us a license to display, distribute, and federate it as part of operating the platform.</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>You must have the right to publish any content you upload (no plagiarism, no copyright infringement).</li>
38
- <li>Content you import must be your own original work.</li>
39
- <li>You may delete your content at any time. Federated copies on remote instances are outside our control.</li>
40
- <li>We may remove content that violates these terms or applicable law.</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>4. Acceptable Use</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>Post illegal content or content that infringes others' rights</li>
49
- <li>Harass, threaten, or abuse other users</li>
50
- <li>Spam, phish, or distribute malware</li>
51
- <li>Attempt to gain unauthorized access to the platform or other users' accounts</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>5. Moderation</h2>
60
- <p>The instance administrator may, at their discretion:</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>Remove content that violates these terms</li>
63
- <li>Suspend or delete accounts that violate these terms</li>
64
- <li>Block federation with other instances</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 aim to handle moderation decisions transparently and fairly.</p>
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>6. Availability and Changes</h2>
71
- <p>We provide {{ siteName }} on an "as is" basis. We do not guarantee uninterrupted availability. We may modify or discontinue the service at any time.</p>
72
- <p>These terms may be updated. Continued use after changes constitutes acceptance.</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>7. Limitation of Liability</h2>
77
- <p>To the fullest extent permitted by law, the instance operator is not liable for any indirect, incidental, or consequential damages arising from your use of the platform. This includes data loss, service interruptions, or actions of other users or federated instances.</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>8. Account Deletion</h2>
82
- <p>You may delete your account at any time from your <NuxtLink to="/settings/account">account settings</NuxtLink>. Account deletion is permanent and removes all your data, content, and activity from this instance. See our <NuxtLink to="/privacy">Privacy Policy</NuxtLink> for details on data retention and federation.</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>9. Contact</h2>
87
- <p>For questions about these terms, contact the administrator of this {{ siteName }} instance.</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
+ }