@commonpub/layer 0.5.4 → 0.5.6
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/views/ExplainerView.vue +36 -1
- package/nuxt.config.ts +5 -0
- package/package.json +8 -8
- package/pages/[type]/[slug]/edit.vue +3 -3
- package/pages/authorize_interaction.vue +188 -0
- package/pages/docs/[siteSlug]/[...pagePath].vue +22 -6
- package/pages/docs/[siteSlug]/edit.vue +41 -2
- package/pages/docs/[siteSlug]/index.vue +17 -5
- package/pages/federated-hubs/[id]/index.vue +29 -2
- package/server/api/federation/remote-follow.post.ts +27 -0
- package/server/api/federation/resolve-uri.post.ts +32 -0
- package/server/api/social/comments.post.ts +13 -2
|
@@ -165,7 +165,18 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
165
165
|
|
|
166
166
|
<template>
|
|
167
167
|
<!-- Scroll viewer for ExplainerDocument format -->
|
|
168
|
-
<
|
|
168
|
+
<div v-if="isDocumentFormat && explainerDoc" class="cpub-scroll-viewer-wrap">
|
|
169
|
+
<ScrollViewer :document="explainerDoc" />
|
|
170
|
+
<NuxtLink
|
|
171
|
+
v-if="isOwner"
|
|
172
|
+
:to="`/${content.type}/${content.slug}/edit`"
|
|
173
|
+
class="cpub-scroll-edit-btn"
|
|
174
|
+
title="Edit explainer"
|
|
175
|
+
aria-label="Edit explainer"
|
|
176
|
+
>
|
|
177
|
+
<i class="fa-solid fa-pen"></i>
|
|
178
|
+
</NuxtLink>
|
|
179
|
+
</div>
|
|
169
180
|
|
|
170
181
|
<!-- Block-based viewer fallback -->
|
|
171
182
|
<div v-else class="cpub-explainer-view">
|
|
@@ -332,6 +343,30 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
332
343
|
</template>
|
|
333
344
|
|
|
334
345
|
<style scoped>
|
|
346
|
+
/* ── SCROLL VIEWER WRAPPER + EDIT BUTTON ── */
|
|
347
|
+
.cpub-scroll-viewer-wrap { position: relative; }
|
|
348
|
+
.cpub-scroll-edit-btn {
|
|
349
|
+
position: fixed;
|
|
350
|
+
bottom: 20px;
|
|
351
|
+
right: 20px;
|
|
352
|
+
width: 40px;
|
|
353
|
+
height: 40px;
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
justify-content: center;
|
|
357
|
+
background: var(--surface, #1a1a1a);
|
|
358
|
+
border: 1px solid var(--border, #333);
|
|
359
|
+
color: var(--text-dim, #999);
|
|
360
|
+
font-size: 14px;
|
|
361
|
+
text-decoration: none;
|
|
362
|
+
z-index: 100;
|
|
363
|
+
transition: background 0.15s, color 0.15s;
|
|
364
|
+
}
|
|
365
|
+
.cpub-scroll-edit-btn:hover {
|
|
366
|
+
background: var(--accent-bg, #1a2a4a);
|
|
367
|
+
color: var(--accent, #5b9cf6);
|
|
368
|
+
}
|
|
369
|
+
|
|
335
370
|
/* ── PROGRESS BAR ── */
|
|
336
371
|
.cpub-progress-line {
|
|
337
372
|
position: fixed;
|
package/nuxt.config.ts
CHANGED
|
@@ -47,6 +47,11 @@ export default defineNuxtConfig({
|
|
|
47
47
|
uiTheme('layouts.css'),
|
|
48
48
|
uiTheme('forms.css'),
|
|
49
49
|
uiTheme('editor-panels.css'),
|
|
50
|
+
// Explainer theme presets only (NOT base.css — it overrides site design system vars)
|
|
51
|
+
'@commonpub/explainer/vue/theme/dark-industrial.css',
|
|
52
|
+
'@commonpub/explainer/vue/theme/punk-zine.css',
|
|
53
|
+
'@commonpub/explainer/vue/theme/paper-teal.css',
|
|
54
|
+
'@commonpub/explainer/vue/theme/clean-light.css',
|
|
50
55
|
],
|
|
51
56
|
runtimeConfig: {
|
|
52
57
|
databaseUrl: '',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/config": "0.8.0",
|
|
53
|
+
"@commonpub/explainer": "0.7.0",
|
|
55
54
|
"@commonpub/docs": "0.6.0",
|
|
55
|
+
"@commonpub/config": "0.8.0",
|
|
56
|
+
"@commonpub/auth": "0.5.0",
|
|
56
57
|
"@commonpub/editor": "0.5.0",
|
|
57
|
-
"@commonpub/explainer": "0.6.4",
|
|
58
|
-
"@commonpub/learning": "0.5.0",
|
|
59
|
-
"@commonpub/schema": "0.8.17",
|
|
60
58
|
"@commonpub/protocol": "0.9.6",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
59
|
+
"@commonpub/schema": "0.8.17",
|
|
60
|
+
"@commonpub/learning": "0.5.0",
|
|
61
|
+
"@commonpub/ui": "0.8.4",
|
|
62
|
+
"@commonpub/server": "2.25.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -386,7 +386,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
|
386
386
|
<div class="cpub-mode-tabs">
|
|
387
387
|
<button :class="['cpub-mode-tab', { active: mode === 'write' }]" @click="mode = 'write'">Write</button>
|
|
388
388
|
<button :class="['cpub-mode-tab', { active: mode === 'preview' }]" @click="enterPreview">Preview</button>
|
|
389
|
-
<button :class="['cpub-mode-tab', { active: mode === 'code' }]" @click="mode = 'code'">Code</button>
|
|
389
|
+
<button v-if="!isExplainer" :class="['cpub-mode-tab', { active: mode === 'code' }]" @click="mode = 'code'">Code</button>
|
|
390
390
|
</div>
|
|
391
391
|
<div class="cpub-topbar-spacer" />
|
|
392
392
|
<div class="cpub-topbar-actions">
|
|
@@ -466,7 +466,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
|
466
466
|
slug: (metadata.slug as string) || 'preview',
|
|
467
467
|
subtitle: null,
|
|
468
468
|
description: (metadata.description as string) || null,
|
|
469
|
-
content: blockEditor.toBlockTuples(),
|
|
469
|
+
content: isExplainer ? (explainerDocLatest ?? explainerDocInit) : blockEditor.toBlockTuples(),
|
|
470
470
|
coverImageUrl: (metadata.coverImageUrl as string) || null,
|
|
471
471
|
category: null,
|
|
472
472
|
difficulty: (metadata.difficulty as string) || null,
|
|
@@ -778,7 +778,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
|
778
778
|
inset: 0;
|
|
779
779
|
z-index: 9999;
|
|
780
780
|
background: var(--bg);
|
|
781
|
-
overflow:
|
|
781
|
+
overflow-y: auto;
|
|
782
782
|
}
|
|
783
783
|
|
|
784
784
|
.cpub-preview-close-btn {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* /authorize_interaction — Standard ActivityPub remote interaction endpoint.
|
|
4
|
+
* When a user on another instance wants to follow/interact with content on this instance,
|
|
5
|
+
* their instance redirects them here with ?uri=<actor-or-object-uri>.
|
|
6
|
+
*
|
|
7
|
+
* If logged in: show the resource and offer to follow/interact.
|
|
8
|
+
* If not logged in: redirect to login first, then back here.
|
|
9
|
+
*/
|
|
10
|
+
const route = useRoute();
|
|
11
|
+
const uri = computed(() => (route.query.uri as string) || '');
|
|
12
|
+
|
|
13
|
+
const { isAuthenticated } = useAuth();
|
|
14
|
+
const toast = useToast();
|
|
15
|
+
|
|
16
|
+
const loading = ref(false);
|
|
17
|
+
const resolved = ref<{ type: string; name: string; url: string } | null>(null);
|
|
18
|
+
const error = ref('');
|
|
19
|
+
const actionDone = ref(false);
|
|
20
|
+
|
|
21
|
+
onMounted(async () => {
|
|
22
|
+
if (!uri.value) {
|
|
23
|
+
error.value = 'No URI provided.';
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!isAuthenticated.value) {
|
|
28
|
+
// Redirect to login, then back here
|
|
29
|
+
navigateTo(`/auth/login?redirect=${encodeURIComponent(route.fullPath)}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Try to resolve what the URI points to
|
|
34
|
+
loading.value = true;
|
|
35
|
+
try {
|
|
36
|
+
// Check if it's a hub actor URI
|
|
37
|
+
const result = await $fetch<{ type: string; name: string; url: string }>('/api/federation/resolve-uri', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
body: { uri: uri.value },
|
|
40
|
+
}).catch(() => null);
|
|
41
|
+
|
|
42
|
+
if (result) {
|
|
43
|
+
resolved.value = result;
|
|
44
|
+
} else {
|
|
45
|
+
// Fallback: just show the URI and offer to open it
|
|
46
|
+
resolved.value = { type: 'unknown', name: uri.value, url: uri.value };
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
error.value = 'Could not resolve the remote resource.';
|
|
50
|
+
} finally {
|
|
51
|
+
loading.value = false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function handleFollow(): Promise<void> {
|
|
56
|
+
if (!uri.value) return;
|
|
57
|
+
loading.value = true;
|
|
58
|
+
try {
|
|
59
|
+
// Try to follow as a remote actor (user or hub)
|
|
60
|
+
await $fetch('/api/federation/remote-follow', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
body: { uri: uri.value },
|
|
63
|
+
});
|
|
64
|
+
actionDone.value = true;
|
|
65
|
+
toast.success('Follow request sent');
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error('Failed to send follow request');
|
|
68
|
+
} finally {
|
|
69
|
+
loading.value = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
useSeoMeta({
|
|
74
|
+
title: 'Authorize Interaction',
|
|
75
|
+
robots: 'noindex',
|
|
76
|
+
});
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="cpub-authorize-page">
|
|
81
|
+
<div class="cpub-authorize-card">
|
|
82
|
+
<h1 class="cpub-authorize-title">Authorize Interaction</h1>
|
|
83
|
+
|
|
84
|
+
<div v-if="loading" class="cpub-authorize-loading">
|
|
85
|
+
<i class="fa-solid fa-spinner fa-spin"></i> Resolving...
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div v-else-if="error" class="cpub-authorize-error">
|
|
89
|
+
<i class="fa-solid fa-triangle-exclamation"></i> {{ error }}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div v-else-if="resolved" class="cpub-authorize-content">
|
|
93
|
+
<p class="cpub-authorize-desc">
|
|
94
|
+
You are about to interact with a remote resource:
|
|
95
|
+
</p>
|
|
96
|
+
<div class="cpub-authorize-resource">
|
|
97
|
+
<strong>{{ resolved.name }}</strong>
|
|
98
|
+
<span v-if="resolved.type !== 'unknown'" class="cpub-authorize-type">{{ resolved.type }}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<code class="cpub-authorize-uri">{{ uri }}</code>
|
|
101
|
+
|
|
102
|
+
<div v-if="actionDone" class="cpub-authorize-success">
|
|
103
|
+
<i class="fa-solid fa-check"></i> Follow request sent successfully.
|
|
104
|
+
</div>
|
|
105
|
+
<div v-else class="cpub-authorize-actions">
|
|
106
|
+
<button class="cpub-btn cpub-btn-primary" :disabled="loading" @click="handleFollow">
|
|
107
|
+
<i class="fa-solid fa-user-plus"></i> Follow
|
|
108
|
+
</button>
|
|
109
|
+
<a :href="uri" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
|
|
110
|
+
<i class="fa-solid fa-arrow-up-right-from-square"></i> View Original
|
|
111
|
+
</a>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<style scoped>
|
|
119
|
+
.cpub-authorize-page {
|
|
120
|
+
display: flex;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
align-items: flex-start;
|
|
123
|
+
padding: 60px 16px;
|
|
124
|
+
min-height: 60vh;
|
|
125
|
+
}
|
|
126
|
+
.cpub-authorize-card {
|
|
127
|
+
max-width: 520px;
|
|
128
|
+
width: 100%;
|
|
129
|
+
background: var(--surface);
|
|
130
|
+
border: var(--border-width-default) solid var(--border);
|
|
131
|
+
padding: 32px;
|
|
132
|
+
}
|
|
133
|
+
.cpub-authorize-title {
|
|
134
|
+
font-size: 18px;
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
margin-bottom: 20px;
|
|
137
|
+
}
|
|
138
|
+
.cpub-authorize-loading {
|
|
139
|
+
color: var(--text-dim);
|
|
140
|
+
font-size: 14px;
|
|
141
|
+
}
|
|
142
|
+
.cpub-authorize-error {
|
|
143
|
+
color: var(--red, #ef4444);
|
|
144
|
+
font-size: 14px;
|
|
145
|
+
}
|
|
146
|
+
.cpub-authorize-desc {
|
|
147
|
+
font-size: 13px;
|
|
148
|
+
color: var(--text-dim);
|
|
149
|
+
margin-bottom: 16px;
|
|
150
|
+
}
|
|
151
|
+
.cpub-authorize-resource {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
margin-bottom: 8px;
|
|
156
|
+
}
|
|
157
|
+
.cpub-authorize-resource strong {
|
|
158
|
+
font-size: 15px;
|
|
159
|
+
}
|
|
160
|
+
.cpub-authorize-type {
|
|
161
|
+
font-family: var(--font-mono);
|
|
162
|
+
font-size: 10px;
|
|
163
|
+
text-transform: uppercase;
|
|
164
|
+
letter-spacing: 0.08em;
|
|
165
|
+
color: var(--accent);
|
|
166
|
+
background: var(--accent-bg);
|
|
167
|
+
padding: 2px 6px;
|
|
168
|
+
}
|
|
169
|
+
.cpub-authorize-uri {
|
|
170
|
+
display: block;
|
|
171
|
+
font-size: 11px;
|
|
172
|
+
color: var(--text-faint);
|
|
173
|
+
word-break: break-all;
|
|
174
|
+
margin-bottom: 20px;
|
|
175
|
+
padding: 8px;
|
|
176
|
+
background: var(--surface2);
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
}
|
|
179
|
+
.cpub-authorize-success {
|
|
180
|
+
color: var(--green, #22c55e);
|
|
181
|
+
font-size: 14px;
|
|
182
|
+
font-weight: 600;
|
|
183
|
+
}
|
|
184
|
+
.cpub-authorize-actions {
|
|
185
|
+
display: flex;
|
|
186
|
+
gap: 8px;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
@@ -9,9 +9,17 @@ const pagePath = computed(() => {
|
|
|
9
9
|
return Array.isArray(p) ? p[p.length - 1] : p;
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
const selectedVersion = ref('');
|
|
13
|
+
|
|
12
14
|
const { data: site } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
|
|
13
|
-
const { data: nav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() =>
|
|
14
|
-
const
|
|
15
|
+
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
16
|
+
const base = `/api/docs/${siteSlug.value}/nav`;
|
|
17
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
18
|
+
});
|
|
19
|
+
const { data: pages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
20
|
+
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
21
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
22
|
+
});
|
|
15
23
|
|
|
16
24
|
// Fetch the rendered page (server-side markdown rendering or block content)
|
|
17
25
|
interface RenderedPage {
|
|
@@ -37,7 +45,10 @@ const blockContent = computed<BlockTuple[]>(() => {
|
|
|
37
45
|
});
|
|
38
46
|
|
|
39
47
|
const { data: renderedPage, pending: pagePending, error: pageError, refresh: refreshPage } = useLazyFetch<RenderedPage>(
|
|
40
|
-
() =>
|
|
48
|
+
() => {
|
|
49
|
+
const base = `/api/docs/${siteSlug.value}/pages/${pagePath.value}`;
|
|
50
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
51
|
+
},
|
|
41
52
|
{ key: `doc-page-${siteSlug.value}-${pagePath.value}` },
|
|
42
53
|
);
|
|
43
54
|
|
|
@@ -178,15 +189,20 @@ watch(searchQuery, (q) => {
|
|
|
178
189
|
searchTimeout = setTimeout(handleSearch, 300);
|
|
179
190
|
});
|
|
180
191
|
|
|
181
|
-
// Version switching
|
|
182
|
-
const selectedVersion = ref('');
|
|
192
|
+
// Version switching — initialize from site data
|
|
183
193
|
watch(site, (s) => {
|
|
184
194
|
if (s?.versions?.length) {
|
|
185
195
|
const def = s.versions.find((v: { isDefault: boolean }) => v.isDefault) ?? s.versions[0];
|
|
186
|
-
if (def) selectedVersion.value = def.version;
|
|
196
|
+
if (def && !selectedVersion.value) selectedVersion.value = def.version;
|
|
187
197
|
}
|
|
188
198
|
}, { immediate: true });
|
|
189
199
|
|
|
200
|
+
// Reload page content and nav when version changes
|
|
201
|
+
watch(selectedVersion, () => {
|
|
202
|
+
refreshNav();
|
|
203
|
+
refreshPage();
|
|
204
|
+
});
|
|
205
|
+
|
|
190
206
|
// Mobile sidebar
|
|
191
207
|
const sidebarOpen = ref(false);
|
|
192
208
|
|
|
@@ -10,8 +10,26 @@ const siteSlug = computed(() => route.params.siteSlug as string);
|
|
|
10
10
|
const { show: toast } = useToast();
|
|
11
11
|
|
|
12
12
|
// ═══ DATA FETCHING ═══
|
|
13
|
-
const { data: site, refresh: refreshSite } = await useFetch<{ id: string; name: string; slug: string; description: string; ownerId: string }>(() => `/api/docs/${siteSlug.value}`);
|
|
14
|
-
|
|
13
|
+
const { data: site, refresh: refreshSite } = await useFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions?: Array<{ id: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
|
|
14
|
+
|
|
15
|
+
// Version selector
|
|
16
|
+
const selectedVersion = ref('');
|
|
17
|
+
watch(site, (s) => {
|
|
18
|
+
if (s?.versions?.length && !selectedVersion.value) {
|
|
19
|
+
const def = s.versions.find((v) => v.isDefault) ?? s.versions[0];
|
|
20
|
+
if (def) selectedVersion.value = def.version;
|
|
21
|
+
}
|
|
22
|
+
}, { immediate: true });
|
|
23
|
+
|
|
24
|
+
const { data: rawPages, refresh: refreshPages } = await useFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null; content: string | BlockTuple[] | null; format?: string }>>(() => {
|
|
25
|
+
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
26
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
watch(selectedVersion, () => {
|
|
30
|
+
selectedPageId.value = null;
|
|
31
|
+
refreshPages();
|
|
32
|
+
});
|
|
15
33
|
|
|
16
34
|
useSeoMeta({ title: () => `Edit ${site.value?.name ?? 'Docs'} — ${useSiteName()}` });
|
|
17
35
|
|
|
@@ -478,6 +496,13 @@ async function createVersion(): Promise<void> {
|
|
|
478
496
|
<span class="cpub-docs-left-label">Pages</span>
|
|
479
497
|
<span class="cpub-docs-page-count">{{ pages.length }}</span>
|
|
480
498
|
</div>
|
|
499
|
+
<div v-if="site?.versions && site.versions.length > 1" class="cpub-docs-version-select">
|
|
500
|
+
<select v-model="selectedVersion" class="cpub-docs-version-dropdown" aria-label="Select version">
|
|
501
|
+
<option v-for="v in site.versions" :key="v.id" :value="v.version">
|
|
502
|
+
{{ v.version }}{{ v.isDefault ? ' (latest)' : '' }}
|
|
503
|
+
</option>
|
|
504
|
+
</select>
|
|
505
|
+
</div>
|
|
481
506
|
<EditorsDocsPageTree
|
|
482
507
|
:pages="treePages"
|
|
483
508
|
:selected-page-id="selectedPageId"
|
|
@@ -794,6 +819,20 @@ async function createVersion(): Promise<void> {
|
|
|
794
819
|
color: var(--text-faint);
|
|
795
820
|
}
|
|
796
821
|
|
|
822
|
+
.cpub-docs-version-select {
|
|
823
|
+
padding: 4px 0 6px;
|
|
824
|
+
}
|
|
825
|
+
.cpub-docs-version-dropdown {
|
|
826
|
+
width: 100%;
|
|
827
|
+
font-family: var(--font-mono);
|
|
828
|
+
font-size: 11px;
|
|
829
|
+
padding: 4px 6px;
|
|
830
|
+
background: var(--surface);
|
|
831
|
+
border: var(--border-width-default) solid var(--border2);
|
|
832
|
+
color: var(--text);
|
|
833
|
+
cursor: pointer;
|
|
834
|
+
}
|
|
835
|
+
|
|
797
836
|
.cpub-docs-page-count {
|
|
798
837
|
font-family: var(--font-mono);
|
|
799
838
|
font-size: 9px;
|
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const siteSlug = computed(() => route.params.siteSlug as string);
|
|
4
4
|
|
|
5
|
+
const selectedVersion = ref('');
|
|
6
|
+
|
|
5
7
|
const { data: site, pending: sitePending, error: siteError, refresh: refreshSite } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
|
|
6
|
-
const { data: nav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() =>
|
|
7
|
-
const
|
|
8
|
+
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
9
|
+
const base = `/api/docs/${siteSlug.value}/nav`;
|
|
10
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
11
|
+
});
|
|
12
|
+
const { data: pages, refresh: refreshPages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
13
|
+
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
14
|
+
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
15
|
+
});
|
|
8
16
|
|
|
9
17
|
const { user } = useAuth();
|
|
10
18
|
const isOwner = computed(() => site.value && user.value && site.value.ownerId === user.value.id);
|
|
@@ -44,15 +52,19 @@ function toggleSection(id: string): void {
|
|
|
44
52
|
else expandedSections.value.add(id);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
// Version selector
|
|
48
|
-
const selectedVersion = ref('');
|
|
55
|
+
// Version selector — initialize from site data, refresh nav on change
|
|
49
56
|
watch(site, (s) => {
|
|
50
57
|
if (s?.versions?.length) {
|
|
51
58
|
const def = s.versions.find((v: { isDefault: boolean }) => v.isDefault) ?? s.versions[0];
|
|
52
|
-
if (def) selectedVersion.value = def.version;
|
|
59
|
+
if (def && !selectedVersion.value) selectedVersion.value = def.version;
|
|
53
60
|
}
|
|
54
61
|
}, { immediate: true });
|
|
55
62
|
|
|
63
|
+
watch(selectedVersion, () => {
|
|
64
|
+
refreshNav();
|
|
65
|
+
refreshPages();
|
|
66
|
+
});
|
|
67
|
+
|
|
56
68
|
// Search
|
|
57
69
|
const searchQuery = ref('');
|
|
58
70
|
const searchOpen = ref(false);
|
|
@@ -206,6 +206,33 @@ async function handleDiscPost(): Promise<void> {
|
|
|
206
206
|
const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
|
|
207
207
|
|
|
208
208
|
const remoteFollowRef = ref<{ show: () => void } | null>(null);
|
|
209
|
+
const hubFollowing = ref(false);
|
|
210
|
+
const hubFollowStatus = ref('');
|
|
211
|
+
|
|
212
|
+
/** Follow the hub — if logged in, call API directly; otherwise show the remote follow modal */
|
|
213
|
+
async function handleJoinHub(): Promise<void> {
|
|
214
|
+
if (isAuthenticated.value && hub.value) {
|
|
215
|
+
// Logged-in user: call the hub-follow API directly
|
|
216
|
+
hubFollowing.value = true;
|
|
217
|
+
try {
|
|
218
|
+
const result = await $fetch<{ success: boolean; status: string }>('/api/federation/hub-follow', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: { federatedHubId: hub.value.id },
|
|
221
|
+
});
|
|
222
|
+
hubFollowStatus.value = result.status;
|
|
223
|
+
toast.success(result.status === 'accepted' ? 'Now following this hub' : 'Follow request sent');
|
|
224
|
+
await refreshHub();
|
|
225
|
+
} catch (err: unknown) {
|
|
226
|
+
const msg = err instanceof Error ? err.message : 'Failed to follow hub';
|
|
227
|
+
toast.error(msg);
|
|
228
|
+
} finally {
|
|
229
|
+
hubFollowing.value = false;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Not logged in: show the remote follow modal
|
|
233
|
+
remoteFollowRef.value?.show();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
209
236
|
|
|
210
237
|
// --- Like state tracking ---
|
|
211
238
|
const likedPostIds = ref<Set<string>>(new Set());
|
|
@@ -272,8 +299,8 @@ async function handlePostVote(postId: string): Promise<void> {
|
|
|
272
299
|
<span v-if="mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-mirrored">
|
|
273
300
|
<i class="fa-solid fa-globe"></i> Mirrored
|
|
274
301
|
</span>
|
|
275
|
-
<button v-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="
|
|
276
|
-
<i class="fa-solid fa-user-plus"></i> Join from your instance
|
|
302
|
+
<button v-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing" @click="handleJoinHub">
|
|
303
|
+
<i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : 'Join from your instance' }}
|
|
277
304
|
</button>
|
|
278
305
|
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
|
|
279
306
|
<i class="fa-solid fa-arrow-up-right-from-square"></i> Visit original
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sendFollow, resolveRemoteActor } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const schema = z.object({
|
|
5
|
+
uri: z.string().url(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Follow a remote actor by URI. Used by /authorize_interaction.
|
|
10
|
+
* Resolves the actor, then sends an AP Follow activity.
|
|
11
|
+
*/
|
|
12
|
+
export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
|
|
13
|
+
requireFeature('federation');
|
|
14
|
+
const user = requireAuth(event);
|
|
15
|
+
const db = useDB();
|
|
16
|
+
const config = useConfig();
|
|
17
|
+
const { uri } = await parseBody(event, schema);
|
|
18
|
+
|
|
19
|
+
// Resolve actor to ensure it's cached
|
|
20
|
+
const actor = await resolveRemoteActor(db, uri);
|
|
21
|
+
if (!actor) {
|
|
22
|
+
throw createError({ statusCode: 404, statusMessage: 'Could not resolve remote actor' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await sendFollow(db, user.id, uri, config.instance.domain);
|
|
26
|
+
return { success: true };
|
|
27
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolveRemoteActor } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const schema = z.object({
|
|
5
|
+
uri: z.string().url(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a remote AP URI to a displayable resource.
|
|
10
|
+
* Used by /authorize_interaction to show what the user is about to follow.
|
|
11
|
+
*/
|
|
12
|
+
export default defineEventHandler(async (event): Promise<{ type: string; name: string; url: string }> => {
|
|
13
|
+
requireFeature('federation');
|
|
14
|
+
requireAuth(event);
|
|
15
|
+
const db = useDB();
|
|
16
|
+
const { uri } = await parseBody(event, schema);
|
|
17
|
+
|
|
18
|
+
const actor = await resolveRemoteActor(db, uri);
|
|
19
|
+
if (actor) {
|
|
20
|
+
const actorType = (actor as Record<string, unknown>).type as string || 'Person';
|
|
21
|
+
const name = (actor as Record<string, unknown>).name as string
|
|
22
|
+
|| (actor as Record<string, unknown>).preferredUsername as string
|
|
23
|
+
|| uri;
|
|
24
|
+
return {
|
|
25
|
+
type: actorType === 'Group' ? 'Hub' : actorType,
|
|
26
|
+
name,
|
|
27
|
+
url: uri,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { type: 'unknown', name: uri, url: uri };
|
|
32
|
+
});
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { createComment } from '@commonpub/server';
|
|
1
|
+
import { createComment, onContentCommented } from '@commonpub/server';
|
|
2
2
|
import type { CommentItem } from '@commonpub/server';
|
|
3
3
|
import { createCommentSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
|
+
/** Content types that should federate comments */
|
|
6
|
+
const FEDERABLE_COMMENT_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
|
|
7
|
+
|
|
5
8
|
export default defineEventHandler(async (event): Promise<CommentItem> => {
|
|
6
9
|
const user = requireAuth(event);
|
|
7
10
|
const db = useDB();
|
|
11
|
+
const config = useConfig();
|
|
8
12
|
const input = await parseBody(event, createCommentSchema);
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
const comment = await createComment(db, user.id, input);
|
|
15
|
+
|
|
16
|
+
// Federate comment on content items (non-blocking)
|
|
17
|
+
if (FEDERABLE_COMMENT_TYPES.has(input.targetType)) {
|
|
18
|
+
onContentCommented(db, comment.id, user.id, input.targetType, input.targetId, config).catch(() => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return comment;
|
|
11
22
|
});
|