@commonpub/layer 0.2.0 → 0.3.1
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,4 +1,10 @@
|
|
|
1
|
-
/** Returns the configured site name from runtime config (defaults to 'CommonPub').
|
|
1
|
+
/** Returns the configured site name from runtime config (defaults to 'CommonPub').
|
|
2
|
+
* Safe to call inside lazy SEO resolvers — falls back to 'CommonPub' if
|
|
3
|
+
* the Nuxt instance is unavailable (e.g. during SSR head rendering). */
|
|
2
4
|
export function useSiteName(): string {
|
|
3
|
-
|
|
5
|
+
try {
|
|
6
|
+
return (useRuntimeConfig().public.siteName as string) || 'CommonPub';
|
|
7
|
+
} catch {
|
|
8
|
+
return 'CommonPub';
|
|
9
|
+
}
|
|
4
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -45,15 +45,15 @@
|
|
|
45
45
|
"vue": "^3.4.0",
|
|
46
46
|
"vue-router": "^4.3.0",
|
|
47
47
|
"zod": "^4.3.6",
|
|
48
|
-
"@commonpub/auth": "0.5.0",
|
|
49
48
|
"@commonpub/config": "0.7.0",
|
|
50
|
-
"@commonpub/docs": "0.5.0",
|
|
51
49
|
"@commonpub/editor": "0.5.0",
|
|
50
|
+
"@commonpub/auth": "0.5.0",
|
|
51
|
+
"@commonpub/docs": "0.5.0",
|
|
52
52
|
"@commonpub/learning": "0.5.0",
|
|
53
|
+
"@commonpub/ui": "0.7.1",
|
|
53
54
|
"@commonpub/protocol": "0.9.4",
|
|
54
55
|
"@commonpub/schema": "0.8.8",
|
|
55
|
-
"@commonpub/server": "2.7.0"
|
|
56
|
-
"@commonpub/ui": "0.7.1"
|
|
56
|
+
"@commonpub/server": "2.7.0"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {}
|
|
59
59
|
}
|
package/pages/[type]/index.vue
CHANGED
|
@@ -3,10 +3,11 @@ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/
|
|
|
3
3
|
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const contentType = computed(() => route.params.type as string);
|
|
6
|
+
const siteName = useSiteName();
|
|
6
7
|
|
|
7
8
|
useSeoMeta({
|
|
8
|
-
title: () => `${contentType.value} — ${
|
|
9
|
-
description: () => `Browse ${contentType.value} on
|
|
9
|
+
title: () => `${contentType.value} — ${siteName}`,
|
|
10
|
+
description: () => `Browse ${contentType.value} on ${siteName}.`,
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
const sortBy = ref('recent');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { FederatedHubListItem, FederatedHubPostItem } from '@commonpub/server';
|
|
3
|
-
import type { HubViewModel, HubPostViewModel, HubTabDef } from '../../../types/hub';
|
|
3
|
+
import type { HubViewModel, HubPostViewModel, HubMemberViewModel, HubTabDef } from '../../../types/hub';
|
|
4
4
|
|
|
5
5
|
const route = useRoute();
|
|
6
6
|
const id = route.params.id as string;
|
|
@@ -11,7 +11,7 @@ const { data: posts, refresh: refreshPosts } = useLazyFetch<{ items: FederatedHu
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
useSeoMeta({
|
|
14
|
-
title: () => hub.value ? `${hub.value.name}
|
|
14
|
+
title: () => hub.value ? `${hub.value.name} — ${useSiteName()}` : 'Federated Hub',
|
|
15
15
|
description: () => hub.value?.description || '',
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -24,7 +24,10 @@ if (hub.value?.url) {
|
|
|
24
24
|
|
|
25
25
|
const { isAuthenticated } = useAuth();
|
|
26
26
|
const toast = useToast();
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
const hubType = computed(() => (hub.value?.hubType as 'community' | 'product' | 'company') ?? 'community');
|
|
29
|
+
const initialTab = hubType.value === 'community' ? 'feed' : 'overview';
|
|
30
|
+
const activeTab = ref(initialTab);
|
|
28
31
|
|
|
29
32
|
// --- Map to view models ---
|
|
30
33
|
const hubVM = computed<HubViewModel | null>(() => {
|
|
@@ -34,14 +37,14 @@ const hubVM = computed<HubViewModel | null>(() => {
|
|
|
34
37
|
description: hub.value.description,
|
|
35
38
|
iconUrl: hub.value.iconUrl,
|
|
36
39
|
bannerUrl: hub.value.bannerUrl,
|
|
37
|
-
hubType:
|
|
40
|
+
hubType: hubType.value,
|
|
38
41
|
memberCount: hub.value.memberCount,
|
|
39
42
|
postCount: hub.value.postCount,
|
|
40
43
|
foundedLabel: null,
|
|
41
44
|
isOfficial: false,
|
|
42
45
|
joinPolicy: null,
|
|
43
|
-
categories: null,
|
|
44
|
-
website: null,
|
|
46
|
+
categories: (hub.value as unknown as Record<string, unknown>).categories as string[] | null ?? null,
|
|
47
|
+
website: (hub.value as unknown as Record<string, unknown>).website as string | null ?? null,
|
|
45
48
|
};
|
|
46
49
|
});
|
|
47
50
|
|
|
@@ -75,19 +78,62 @@ const postsVM = computed<HubPostViewModel[]>(() => {
|
|
|
75
78
|
});
|
|
76
79
|
});
|
|
77
80
|
|
|
81
|
+
// Extract shared content posts for "Projects" tab
|
|
82
|
+
const sharedContentPosts = computed(() =>
|
|
83
|
+
postsVM.value.filter(p => p.sharedContent),
|
|
84
|
+
);
|
|
85
|
+
|
|
78
86
|
const discussionPosts = computed(() =>
|
|
79
87
|
postsVM.value.filter(p => p.type === 'text' || p.type === 'discussion' || p.type === 'question'),
|
|
80
88
|
);
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
]
|
|
90
|
+
// Hub rules (from federated metadata)
|
|
91
|
+
const hubRules = computed<string[]>(() => {
|
|
92
|
+
const raw = (hub.value as unknown as Record<string, unknown>)?.rules;
|
|
93
|
+
if (!raw) return [];
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw as string);
|
|
96
|
+
if (Array.isArray(parsed)) return parsed as string[];
|
|
97
|
+
} catch { /* not JSON */ }
|
|
98
|
+
return (raw as string).split('\n').map((r: string) => r.trim()).filter(Boolean);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- Tab definitions (matching local hub structure) ---
|
|
102
|
+
const tabDefs = computed<HubTabDef[]>(() => {
|
|
103
|
+
if (hubType.value === 'product') {
|
|
104
|
+
return [
|
|
105
|
+
{ value: 'overview', label: 'Overview', icon: 'fa-solid fa-info-circle' },
|
|
106
|
+
{ value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: sharedContentPosts.value.length || undefined },
|
|
107
|
+
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (hubType.value === 'company') {
|
|
111
|
+
return [
|
|
112
|
+
{ value: 'overview', label: 'Overview', icon: 'fa-solid fa-building' },
|
|
113
|
+
{ value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: sharedContentPosts.value.length || undefined },
|
|
114
|
+
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
{ value: 'feed', label: 'Feed', icon: 'fa-solid fa-rss', count: hub.value?.postCount },
|
|
119
|
+
{ value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open', count: sharedContentPosts.value.length || undefined },
|
|
120
|
+
{ value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments' },
|
|
121
|
+
{ value: 'members', label: 'Members', icon: 'fa-solid fa-users', count: hub.value?.memberCount },
|
|
122
|
+
];
|
|
123
|
+
});
|
|
86
124
|
|
|
87
125
|
// --- Compose (posts to remote hub via federation) ---
|
|
88
126
|
const newPostContent = ref('');
|
|
127
|
+
const newPostType = ref<'text' | 'question' | 'discussion' | 'showcase'>('text');
|
|
89
128
|
const posting = ref(false);
|
|
90
129
|
|
|
130
|
+
const postTypeOptions = [
|
|
131
|
+
{ value: 'text', label: 'Post', icon: 'fa-solid fa-pen' },
|
|
132
|
+
{ value: 'question', label: 'Question', icon: 'fa-solid fa-circle-question' },
|
|
133
|
+
{ value: 'discussion', label: 'Discussion', icon: 'fa-solid fa-comments' },
|
|
134
|
+
{ value: 'showcase', label: 'Showcase', icon: 'fa-solid fa-image' },
|
|
135
|
+
];
|
|
136
|
+
|
|
91
137
|
async function handlePost(): Promise<void> {
|
|
92
138
|
if (!newPostContent.value.trim() || !hub.value?.actorUri) return;
|
|
93
139
|
posting.value = true;
|
|
@@ -98,10 +144,12 @@ async function handlePost(): Promise<void> {
|
|
|
98
144
|
federatedHubId: id,
|
|
99
145
|
hubActorUri: hub.value.actorUri,
|
|
100
146
|
content: newPostContent.value,
|
|
147
|
+
type: newPostType.value,
|
|
101
148
|
},
|
|
102
149
|
});
|
|
103
150
|
newPostContent.value = '';
|
|
104
|
-
|
|
151
|
+
newPostType.value = 'text';
|
|
152
|
+
toast.success('Post sent to hub via federation');
|
|
105
153
|
await Promise.all([refreshHub(), refreshPosts()]);
|
|
106
154
|
} catch {
|
|
107
155
|
toast.error('Failed to post — the remote hub may not accept posts from this instance');
|
|
@@ -110,6 +158,35 @@ async function handlePost(): Promise<void> {
|
|
|
110
158
|
}
|
|
111
159
|
}
|
|
112
160
|
|
|
161
|
+
// --- Discussion compose ---
|
|
162
|
+
const newDiscContent = ref('');
|
|
163
|
+
const newDiscType = ref<'discussion' | 'question'>('discussion');
|
|
164
|
+
const discPosting = ref(false);
|
|
165
|
+
|
|
166
|
+
async function handleDiscPost(): Promise<void> {
|
|
167
|
+
if (!newDiscContent.value.trim() || !hub.value?.actorUri) return;
|
|
168
|
+
discPosting.value = true;
|
|
169
|
+
try {
|
|
170
|
+
await $fetch('/api/federation/hub-post' as string, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
body: {
|
|
173
|
+
federatedHubId: id,
|
|
174
|
+
hubActorUri: hub.value.actorUri,
|
|
175
|
+
content: newDiscContent.value,
|
|
176
|
+
type: newDiscType.value,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
newDiscContent.value = '';
|
|
180
|
+
newDiscType.value = 'discussion';
|
|
181
|
+
toast.success('Discussion posted via federation');
|
|
182
|
+
await Promise.all([refreshHub(), refreshPosts()]);
|
|
183
|
+
} catch {
|
|
184
|
+
toast.error('Failed to post discussion');
|
|
185
|
+
} finally {
|
|
186
|
+
discPosting.value = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
113
190
|
// --- Follow hub ---
|
|
114
191
|
const followStatus = computed(() => hub.value?.followStatus ?? 'pending');
|
|
115
192
|
const following = ref(false);
|
|
@@ -126,7 +203,7 @@ async function handleFollowHub(): Promise<void> {
|
|
|
126
203
|
body: { federatedHubId: id },
|
|
127
204
|
});
|
|
128
205
|
if (hub.value) (hub.value as unknown as Record<string, unknown>).followStatus = result.status;
|
|
129
|
-
toast.success(result.status === 'accepted' ? '
|
|
206
|
+
toast.success(result.status === 'accepted' ? 'Following!' : 'Follow request sent');
|
|
130
207
|
} catch {
|
|
131
208
|
toast.error('Failed to follow hub');
|
|
132
209
|
} finally {
|
|
@@ -148,7 +225,6 @@ async function fetchLikedState(): Promise<void> {
|
|
|
148
225
|
|
|
149
226
|
watch(() => posts.value?.items.length, () => { fetchLikedState(); }, { immediate: true });
|
|
150
227
|
|
|
151
|
-
// --- Vote/like toggle on posts ---
|
|
152
228
|
async function handlePostVote(postId: string): Promise<void> {
|
|
153
229
|
if (!isAuthenticated.value) {
|
|
154
230
|
await navigateTo(`/auth/login?redirect=/federated-hubs/${id}`);
|
|
@@ -174,33 +250,6 @@ async function handlePostVote(postId: string): Promise<void> {
|
|
|
174
250
|
toast.error('Failed to toggle like');
|
|
175
251
|
}
|
|
176
252
|
}
|
|
177
|
-
|
|
178
|
-
// --- Discussion compose ---
|
|
179
|
-
const newDiscContent = ref('');
|
|
180
|
-
const discPosting = ref(false);
|
|
181
|
-
|
|
182
|
-
async function handleDiscPost(): Promise<void> {
|
|
183
|
-
if (!newDiscContent.value.trim() || !hub.value?.actorUri) return;
|
|
184
|
-
discPosting.value = true;
|
|
185
|
-
try {
|
|
186
|
-
await $fetch('/api/federation/hub-post' as string, {
|
|
187
|
-
method: 'POST',
|
|
188
|
-
body: {
|
|
189
|
-
federatedHubId: id,
|
|
190
|
-
hubActorUri: hub.value.actorUri,
|
|
191
|
-
content: newDiscContent.value,
|
|
192
|
-
type: 'discussion',
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
newDiscContent.value = '';
|
|
196
|
-
toast.success('Discussion posted');
|
|
197
|
-
await Promise.all([refreshHub(), refreshPosts()]);
|
|
198
|
-
} catch {
|
|
199
|
-
toast.error('Failed to post discussion');
|
|
200
|
-
} finally {
|
|
201
|
-
discPosting.value = false;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
253
|
</script>
|
|
205
254
|
|
|
206
255
|
<template>
|
|
@@ -215,31 +264,29 @@ async function handleDiscPost(): Promise<void> {
|
|
|
215
264
|
<template #hero>
|
|
216
265
|
<HubHero :hub="hubVM">
|
|
217
266
|
<template #banner-overlay>
|
|
218
|
-
<div class="cpub-fed-
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
</a>
|
|
225
|
-
</div>
|
|
267
|
+
<div class="cpub-fed-indicator">
|
|
268
|
+
<i class="fa-solid fa-globe"></i>
|
|
269
|
+
<span>Mirrored from <strong>{{ hub?.originDomain }}</strong></span>
|
|
270
|
+
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-fed-indicator-link">
|
|
271
|
+
Visit original <i class="fa-solid fa-arrow-up-right-from-square"></i>
|
|
272
|
+
</a>
|
|
226
273
|
</div>
|
|
227
274
|
</template>
|
|
228
275
|
<template #actions>
|
|
229
276
|
<button
|
|
230
277
|
v-if="followStatus !== 'accepted'"
|
|
231
|
-
class="cpub-btn cpub-btn-
|
|
278
|
+
class="cpub-btn cpub-btn-primary"
|
|
232
279
|
:disabled="following || followStatus === 'pending'"
|
|
233
280
|
@click="handleFollowHub"
|
|
234
281
|
>
|
|
235
282
|
<i class="fa-solid fa-rss"></i>
|
|
236
283
|
{{ followStatus === 'pending' ? 'Follow Pending...' : 'Follow Hub' }}
|
|
237
284
|
</button>
|
|
238
|
-
<span v-else class="cpub-
|
|
285
|
+
<span v-else class="cpub-member-badge">
|
|
239
286
|
<i class="fa-solid fa-check"></i> Following
|
|
240
287
|
</span>
|
|
241
288
|
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
|
|
242
|
-
<i class="fa-solid fa-arrow-up-right-from-square"></i> Visit
|
|
289
|
+
<i class="fa-solid fa-arrow-up-right-from-square"></i> Visit
|
|
243
290
|
</a>
|
|
244
291
|
</template>
|
|
245
292
|
<template #badges>
|
|
@@ -248,16 +295,27 @@ async function handleDiscPost(): Promise<void> {
|
|
|
248
295
|
</HubHero>
|
|
249
296
|
</template>
|
|
250
297
|
|
|
251
|
-
<!-- Feed tab -->
|
|
298
|
+
<!-- Feed tab (community hubs) -->
|
|
252
299
|
<HubFeed v-if="activeTab === 'feed'" :posts="postsVM" :interactive="isAuthenticated" :liked-post-ids="likedPostIds" @post-vote="handlePostVote">
|
|
253
300
|
<template v-if="isAuthenticated" #compose>
|
|
254
301
|
<div class="cpub-compose-bar">
|
|
302
|
+
<div class="cpub-compose-types">
|
|
303
|
+
<button
|
|
304
|
+
v-for="opt in postTypeOptions"
|
|
305
|
+
:key="opt.value"
|
|
306
|
+
class="cpub-compose-type-btn"
|
|
307
|
+
:class="{ active: newPostType === opt.value }"
|
|
308
|
+
@click="newPostType = opt.value as typeof newPostType"
|
|
309
|
+
>
|
|
310
|
+
<i :class="opt.icon"></i> {{ opt.label }}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
255
313
|
<div class="cpub-compose-row">
|
|
256
314
|
<input
|
|
257
315
|
v-model="newPostContent"
|
|
258
316
|
class="cpub-compose-input"
|
|
259
317
|
type="text"
|
|
260
|
-
placeholder="
|
|
318
|
+
:placeholder="newPostType === 'question' ? 'Ask a question...' : newPostType === 'discussion' ? 'Start a discussion...' : 'Write a post...'"
|
|
261
319
|
@keydown.enter="handlePost"
|
|
262
320
|
/>
|
|
263
321
|
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="posting" @click="handlePost">
|
|
@@ -265,12 +323,44 @@ async function handleDiscPost(): Promise<void> {
|
|
|
265
323
|
</button>
|
|
266
324
|
</div>
|
|
267
325
|
<p class="cpub-fed-compose-hint">
|
|
268
|
-
<i class="fa-solid fa-globe"></i>
|
|
326
|
+
<i class="fa-solid fa-globe"></i> Sent to {{ hub?.originDomain }} via ActivityPub
|
|
269
327
|
</p>
|
|
270
328
|
</div>
|
|
271
329
|
</template>
|
|
272
330
|
</HubFeed>
|
|
273
331
|
|
|
332
|
+
<!-- Projects tab (shared content from hub posts) -->
|
|
333
|
+
<div v-else-if="activeTab === 'projects'" class="cpub-hub-projects-tab">
|
|
334
|
+
<div v-if="sharedContentPosts.length" class="cpub-shared-grid">
|
|
335
|
+
<NuxtLink
|
|
336
|
+
v-for="post in sharedContentPosts"
|
|
337
|
+
:key="post.id"
|
|
338
|
+
:to="(post.sharedContent?.url || post.linkTo) ?? ''"
|
|
339
|
+
:target="post.sharedContent?.url ? '_blank' : undefined"
|
|
340
|
+
:rel="post.sharedContent?.url ? 'noopener noreferrer' : undefined"
|
|
341
|
+
class="cpub-shared-card"
|
|
342
|
+
>
|
|
343
|
+
<div v-if="post.sharedContent?.coverImageUrl" class="cpub-shared-card-img">
|
|
344
|
+
<img :src="post.sharedContent.coverImageUrl" :alt="post.sharedContent.title" />
|
|
345
|
+
</div>
|
|
346
|
+
<div class="cpub-shared-card-body">
|
|
347
|
+
<span v-if="post.sharedContent?.type" class="cpub-shared-card-type">{{ post.sharedContent.type }}</span>
|
|
348
|
+
<h3 class="cpub-shared-card-title">{{ post.sharedContent?.title || 'Untitled' }}</h3>
|
|
349
|
+
<p v-if="post.sharedContent?.description" class="cpub-shared-card-desc">{{ post.sharedContent.description }}</p>
|
|
350
|
+
<div class="cpub-shared-card-meta">
|
|
351
|
+
<span class="cpub-shared-card-author">{{ post.author.name }}</span>
|
|
352
|
+
<span class="cpub-shared-card-stat"><i class="fa-solid fa-heart"></i> {{ post.likeCount }}</span>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</NuxtLink>
|
|
356
|
+
</div>
|
|
357
|
+
<div v-else class="cpub-empty-state" style="padding: 48px 24px">
|
|
358
|
+
<div class="cpub-empty-state-icon"><i class="fa-solid fa-folder-open"></i></div>
|
|
359
|
+
<p class="cpub-empty-state-title">No shared projects yet</p>
|
|
360
|
+
<p class="cpub-empty-state-desc">Projects shared in this hub will appear here.</p>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
274
364
|
<!-- Discussions tab -->
|
|
275
365
|
<HubDiscussions v-else-if="activeTab === 'discussions'" :posts="discussionPosts">
|
|
276
366
|
<template v-if="isAuthenticated" #compose>
|
|
@@ -280,17 +370,48 @@ async function handleDiscPost(): Promise<void> {
|
|
|
280
370
|
v-model="newDiscContent"
|
|
281
371
|
class="cpub-compose-input"
|
|
282
372
|
type="text"
|
|
283
|
-
placeholder="Start a discussion
|
|
284
|
-
@keydown.enter="handleDiscPost"
|
|
373
|
+
placeholder="Start a discussion or ask a question..."
|
|
374
|
+
@keydown.enter="newDiscType = 'discussion'; handleDiscPost()"
|
|
285
375
|
/>
|
|
286
|
-
<button class="cpub-btn cpub-btn-sm cpub-btn-primary"
|
|
376
|
+
<button class="cpub-btn cpub-btn-sm" :class="{ 'cpub-btn-primary': newDiscType === 'question' }" @click="newDiscType = 'question'" title="Ask a question">
|
|
377
|
+
<i class="fa-solid fa-circle-question"></i>
|
|
378
|
+
</button>
|
|
379
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="discPosting" @click="newDiscType = 'discussion'; handleDiscPost()">
|
|
287
380
|
<i class="fa-solid fa-paper-plane"></i> Post
|
|
288
381
|
</button>
|
|
289
382
|
</div>
|
|
383
|
+
<p class="cpub-fed-compose-hint">
|
|
384
|
+
<i class="fa-solid fa-globe"></i> Sent to {{ hub?.originDomain }} via ActivityPub
|
|
385
|
+
</p>
|
|
290
386
|
</div>
|
|
291
387
|
</template>
|
|
292
388
|
</HubDiscussions>
|
|
293
389
|
|
|
390
|
+
<!-- Members tab -->
|
|
391
|
+
<div v-else-if="activeTab === 'members'" class="cpub-hub-members-tab">
|
|
392
|
+
<div class="cpub-fed-members-info">
|
|
393
|
+
<div class="cpub-fed-members-stat">
|
|
394
|
+
<i class="fa-solid fa-users"></i>
|
|
395
|
+
<strong>{{ hub?.memberCount ?? 0 }}</strong> members
|
|
396
|
+
</div>
|
|
397
|
+
<p class="cpub-fed-members-note">
|
|
398
|
+
Members are managed on <strong>{{ hub?.originDomain }}</strong>. Visit the original hub to see the full member list and join.
|
|
399
|
+
</p>
|
|
400
|
+
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm" style="margin-top: 12px; display: inline-flex">
|
|
401
|
+
<i class="fa-solid fa-arrow-up-right-from-square"></i> View Members on {{ hub?.originDomain }}
|
|
402
|
+
</a>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<!-- Overview tab (product/company hubs) -->
|
|
407
|
+
<template v-else-if="activeTab === 'overview'">
|
|
408
|
+
<div class="cpub-product-overview">
|
|
409
|
+
<h3 class="cpub-section-title">About</h3>
|
|
410
|
+
<p class="cpub-prose-p">{{ hub?.description || 'No description available.' }}</p>
|
|
411
|
+
</div>
|
|
412
|
+
</template>
|
|
413
|
+
|
|
414
|
+
<!-- Sidebar -->
|
|
294
415
|
<template #sidebar>
|
|
295
416
|
<HubSidebar>
|
|
296
417
|
<HubSidebarCard title="About">
|
|
@@ -306,13 +427,21 @@ async function handleDiscPost(): Promise<void> {
|
|
|
306
427
|
</div>
|
|
307
428
|
</div>
|
|
308
429
|
</HubSidebarCard>
|
|
430
|
+
|
|
431
|
+
<HubSidebarCard v-if="hubRules.length" title="Hub Rules">
|
|
432
|
+
<div v-for="(rule, i) in hubRules" :key="i" class="cpub-rule-item">
|
|
433
|
+
<span class="cpub-rule-num">{{ i + 1 }}</span>
|
|
434
|
+
<span>{{ rule }}</span>
|
|
435
|
+
</div>
|
|
436
|
+
</HubSidebarCard>
|
|
437
|
+
|
|
309
438
|
<HubSidebarCard title="Origin Instance">
|
|
310
439
|
<div class="cpub-origin-info">
|
|
311
440
|
<div class="cpub-origin-domain">
|
|
312
441
|
<i class="fa-solid fa-globe"></i>
|
|
313
442
|
<strong>{{ hub?.originDomain }}</strong>
|
|
314
443
|
</div>
|
|
315
|
-
<p class="cpub-origin-desc">
|
|
444
|
+
<p class="cpub-origin-desc">Mirrored via ActivityPub federation.</p>
|
|
316
445
|
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm" style="margin-top: 8px; display: inline-flex">
|
|
317
446
|
<i class="fa-solid fa-arrow-up-right-from-square"></i> Visit Original
|
|
318
447
|
</a>
|
|
@@ -324,31 +453,43 @@ async function handleDiscPost(): Promise<void> {
|
|
|
324
453
|
</template>
|
|
325
454
|
|
|
326
455
|
<style scoped>
|
|
327
|
-
/*
|
|
328
|
-
.cpub-fed-
|
|
329
|
-
.cpub-fed-
|
|
330
|
-
|
|
456
|
+
/* Federation indicator (inside hero banner) */
|
|
457
|
+
.cpub-fed-indicator { background: var(--accent-bg); border-bottom: 1px solid var(--accent-border); }
|
|
458
|
+
.cpub-fed-indicator {
|
|
459
|
+
padding: 6px 24px;
|
|
331
460
|
display: flex; align-items: center; gap: 8px;
|
|
332
461
|
font-size: 12px; color: var(--text-dim);
|
|
333
462
|
}
|
|
334
|
-
.cpub-fed-
|
|
335
|
-
.cpub-fed-
|
|
463
|
+
.cpub-fed-indicator > i { color: var(--accent); }
|
|
464
|
+
.cpub-fed-indicator-link {
|
|
336
465
|
margin-left: auto; color: var(--accent); font-weight: 600;
|
|
337
466
|
text-decoration: none; white-space: nowrap;
|
|
338
467
|
display: flex; align-items: center; gap: 4px; font-size: 11px;
|
|
339
468
|
}
|
|
340
|
-
.cpub-fed-
|
|
469
|
+
.cpub-fed-indicator-link:hover { text-decoration: underline; }
|
|
341
470
|
|
|
342
471
|
/* Compose */
|
|
343
472
|
.cpub-compose-bar {
|
|
344
|
-
background: var(--surface); border:
|
|
345
|
-
|
|
346
|
-
display: flex; flex-direction: column; gap:
|
|
473
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
474
|
+
padding: 12px 14px; margin-bottom: 16px;
|
|
475
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
347
476
|
}
|
|
348
|
-
.cpub-compose-
|
|
477
|
+
.cpub-compose-types {
|
|
478
|
+
display: flex; gap: 4px; flex-wrap: wrap;
|
|
479
|
+
}
|
|
480
|
+
.cpub-compose-type-btn {
|
|
481
|
+
font-size: 11px; font-weight: 600; padding: 4px 10px;
|
|
482
|
+
border: 1px solid var(--border); background: var(--surface2);
|
|
483
|
+
color: var(--text-dim); cursor: pointer;
|
|
484
|
+
display: flex; align-items: center; gap: 4px;
|
|
485
|
+
}
|
|
486
|
+
.cpub-compose-type-btn.active {
|
|
487
|
+
background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent);
|
|
488
|
+
}
|
|
489
|
+
.cpub-compose-row { display: flex; gap: 8px; align-items: center; }
|
|
349
490
|
.cpub-compose-input {
|
|
350
491
|
flex: 1; background: var(--surface2); border: 1px solid var(--border);
|
|
351
|
-
|
|
492
|
+
padding: 10px 14px; font-size: 0.8125rem;
|
|
352
493
|
color: var(--text); font-family: inherit;
|
|
353
494
|
}
|
|
354
495
|
.cpub-compose-input::placeholder { color: var(--text-faint); }
|
|
@@ -358,13 +499,66 @@ async function handleDiscPost(): Promise<void> {
|
|
|
358
499
|
}
|
|
359
500
|
.cpub-fed-compose-hint i { font-size: 10px; color: var(--accent); }
|
|
360
501
|
|
|
502
|
+
/* Shared content grid (projects tab) */
|
|
503
|
+
.cpub-hub-projects-tab { padding: 0; }
|
|
504
|
+
.cpub-shared-grid {
|
|
505
|
+
display: grid;
|
|
506
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
507
|
+
gap: 16px;
|
|
508
|
+
}
|
|
509
|
+
.cpub-shared-card {
|
|
510
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
511
|
+
overflow: hidden; text-decoration: none; color: inherit;
|
|
512
|
+
transition: border-color 0.15s, transform 0.15s;
|
|
513
|
+
}
|
|
514
|
+
.cpub-shared-card:hover {
|
|
515
|
+
border-color: var(--accent); transform: translateY(-2px);
|
|
516
|
+
}
|
|
517
|
+
.cpub-shared-card-img {
|
|
518
|
+
height: 140px; overflow: hidden; background: var(--surface2);
|
|
519
|
+
}
|
|
520
|
+
.cpub-shared-card-img img { width: 100%; height: 100%; object-fit: cover; }
|
|
521
|
+
.cpub-shared-card-body { padding: 14px; }
|
|
522
|
+
.cpub-shared-card-type {
|
|
523
|
+
font-size: 9px; font-family: var(--font-mono); text-transform: uppercase;
|
|
524
|
+
letter-spacing: 0.08em; color: var(--accent); background: var(--accent-bg);
|
|
525
|
+
border: 1px solid var(--accent-border); padding: 2px 6px;
|
|
526
|
+
display: inline-block; margin-bottom: 6px;
|
|
527
|
+
}
|
|
528
|
+
.cpub-shared-card-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
529
|
+
.cpub-shared-card-desc {
|
|
530
|
+
font-size: 12px; color: var(--text-dim); line-height: 1.5;
|
|
531
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
532
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
533
|
+
margin-bottom: 8px;
|
|
534
|
+
}
|
|
535
|
+
.cpub-shared-card-meta {
|
|
536
|
+
display: flex; align-items: center; gap: 12px; font-size: 11px; color: var(--text-faint);
|
|
537
|
+
}
|
|
538
|
+
.cpub-shared-card-stat { display: flex; align-items: center; gap: 3px; }
|
|
539
|
+
|
|
540
|
+
/* Members tab (federated) */
|
|
541
|
+
.cpub-hub-members-tab { padding: 0; }
|
|
542
|
+
.cpub-fed-members-info {
|
|
543
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
544
|
+
padding: 32px; text-align: center;
|
|
545
|
+
}
|
|
546
|
+
.cpub-fed-members-stat {
|
|
547
|
+
font-size: 20px; font-weight: 700; color: var(--text);
|
|
548
|
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
549
|
+
margin-bottom: 12px;
|
|
550
|
+
}
|
|
551
|
+
.cpub-fed-members-stat i { color: var(--accent); }
|
|
552
|
+
.cpub-fed-members-note {
|
|
553
|
+
font-size: 13px; color: var(--text-dim); line-height: 1.5; max-width: 400px; margin: 0 auto;
|
|
554
|
+
}
|
|
555
|
+
|
|
361
556
|
/* Sidebar */
|
|
362
557
|
.cpub-sidebar-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin-bottom: 12px; }
|
|
363
558
|
.cpub-sidebar-stats { display: flex; gap: 16px; }
|
|
364
559
|
.cpub-sidebar-stat { display: flex; flex-direction: column; font-size: 11px; color: var(--text-faint); }
|
|
365
560
|
.cpub-sidebar-stat strong { font-size: 16px; color: var(--text); font-weight: 700; }
|
|
366
561
|
|
|
367
|
-
/* Origin */
|
|
368
562
|
.cpub-origin-info { font-size: 12px; }
|
|
369
563
|
.cpub-origin-domain {
|
|
370
564
|
display: flex; align-items: center; gap: 6px;
|
|
@@ -373,13 +567,25 @@ async function handleDiscPost(): Promise<void> {
|
|
|
373
567
|
.cpub-origin-domain i { color: var(--accent); font-size: 11px; }
|
|
374
568
|
.cpub-origin-desc { color: var(--text-dim); line-height: 1.5; }
|
|
375
569
|
|
|
570
|
+
/* Rules (same as local hub) */
|
|
571
|
+
.cpub-rule-item {
|
|
572
|
+
display: flex; align-items: flex-start; gap: 8px;
|
|
573
|
+
font-size: 12px; color: var(--text-dim); line-height: 1.5;
|
|
574
|
+
padding: 6px 0;
|
|
575
|
+
}
|
|
576
|
+
.cpub-rule-item + .cpub-rule-item { border-top: 1px solid var(--border2); }
|
|
577
|
+
.cpub-rule-num {
|
|
578
|
+
font-family: var(--font-mono); font-size: 10px; font-weight: 700;
|
|
579
|
+
color: var(--accent); flex-shrink: 0; width: 18px; text-align: center;
|
|
580
|
+
}
|
|
581
|
+
|
|
376
582
|
.cpub-not-found { text-align: center; padding: 60px 20px; color: var(--text-dim); }
|
|
377
583
|
.cpub-not-found h1 { font-size: 1.5rem; color: var(--text); margin-bottom: 8px; }
|
|
378
584
|
|
|
379
585
|
@media (max-width: 768px) {
|
|
380
|
-
.cpub-fed-
|
|
586
|
+
.cpub-fed-indicator { padding: 6px 16px; font-size: 11px; flex-wrap: wrap; }
|
|
381
587
|
.cpub-compose-bar { padding: 10px 12px; }
|
|
382
|
-
.cpub-
|
|
383
|
-
.cpub-
|
|
588
|
+
.cpub-shared-grid { grid-template-columns: 1fr; }
|
|
589
|
+
.cpub-fed-members-info { padding: 24px 16px; }
|
|
384
590
|
}
|
|
385
591
|
</style>
|