@commonpub/layer 0.1.4 → 0.3.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.
@@ -18,16 +18,23 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
18
18
 
19
19
  const title = (fc.title as string) || 'Untitled';
20
20
 
21
- // Parse block content: may be BlockTuple JSON or raw HTML from federation
22
- let content: unknown = fc.content;
23
- if (typeof content === 'string') {
24
- const trimmed = content.trim();
25
- if (trimmed.startsWith('[[') || trimmed.startsWith('[["')) {
26
- try { content = JSON.parse(trimmed); } catch { /* keep as string */ }
27
- }
28
- // If still a string (HTML from federation), wrap as BlockTuple array
29
- if (typeof content === 'string' && content.trim()) {
30
- content = [['paragraph', { html: content }]];
21
+ // Parse block content: prefer cpub:blocks (full fidelity from CommonPub instances),
22
+ // fall back to HTML content (from non-CommonPub instances or legacy federation)
23
+ let content: unknown;
24
+ if (Array.isArray(fc.cpubBlocks) && fc.cpubBlocks.length > 0) {
25
+ // CommonPub→CommonPub: original block structure preserved
26
+ content = fc.cpubBlocks;
27
+ } else {
28
+ content = fc.content;
29
+ if (typeof content === 'string') {
30
+ const trimmed = content.trim();
31
+ if (trimmed.startsWith('[[') || trimmed.startsWith('[["')) {
32
+ try { content = JSON.parse(trimmed); } catch { /* keep as string */ }
33
+ }
34
+ // If still a string (HTML from federation), wrap as BlockTuple array
35
+ if (typeof content === 'string' && content.trim()) {
36
+ content = [['paragraph', { html: content }]];
37
+ }
31
38
  }
32
39
  }
33
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -46,14 +46,14 @@
46
46
  "vue-router": "^4.3.0",
47
47
  "zod": "^4.3.6",
48
48
  "@commonpub/config": "0.7.0",
49
- "@commonpub/docs": "0.5.0",
50
49
  "@commonpub/auth": "0.5.0",
50
+ "@commonpub/docs": "0.5.0",
51
+ "@commonpub/learning": "0.5.0",
51
52
  "@commonpub/editor": "0.5.0",
52
- "@commonpub/protocol": "0.9.3",
53
- "@commonpub/server": "2.6.0",
54
- "@commonpub/schema": "0.8.7",
55
- "@commonpub/ui": "0.7.1",
56
- "@commonpub/learning": "0.5.0"
53
+ "@commonpub/protocol": "0.9.4",
54
+ "@commonpub/server": "2.7.0",
55
+ "@commonpub/schema": "0.8.8",
56
+ "@commonpub/ui": "0.7.1"
57
57
  },
58
58
  "scripts": {}
59
59
  }
@@ -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} (${hub.value.originDomain})` : 'Federated Hub',
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
- const activeTab = ref('feed');
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: (hub.value.hubType as 'community' | 'product' | 'company') ?? 'community',
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
- const tabDefs = computed<HubTabDef[]>(() => [
83
- { value: 'feed', label: 'Feed', icon: 'fa-solid fa-rss', count: hub.value?.postCount },
84
- { value: 'discussions', label: 'Discussions', icon: 'fa-solid fa-comments', count: discussionPosts.value.length || undefined },
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
- toast.success('Post sent to hub');
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' ? 'Already following!' : 'Follow request sent');
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-banner">
219
- <div class="cpub-fed-banner-inner">
220
- <i class="fa-solid fa-globe"></i>
221
- <span>Mirrored from <strong>{{ hub?.originDomain }}</strong></span>
222
- <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-fed-banner-link">
223
- Visit original <i class="fa-solid fa-arrow-up-right-from-square"></i>
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-sm cpub-btn-primary"
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-btn cpub-btn-sm" style="cursor: default; opacity: 0.8">
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 on {{ hub?.originDomain }}
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="Post to this hub (sent via federation)..."
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> Your post will be sent to {{ hub?.originDomain }} via ActivityPub
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 (sent via federation)..."
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" :disabled="discPosting" @click="handleDiscPost">
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">Content is mirrored from this remote CommonPub instance via ActivityPub.</p>
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
- /* Origin banner */
328
- .cpub-fed-banner { background: var(--accent-bg); border-bottom: 1px solid var(--accent-border); }
329
- .cpub-fed-banner-inner {
330
- max-width: 1200px; margin: 0 auto; padding: 8px 24px;
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-banner-inner > i { color: var(--accent); }
335
- .cpub-fed-banner-link {
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-banner-link:hover { text-decoration: underline; }
469
+ .cpub-fed-indicator-link:hover { text-decoration: underline; }
341
470
 
342
471
  /* Compose */
343
472
  .cpub-compose-bar {
344
- background: var(--surface); border: 1px solid var(--border);
345
- border-radius: 12px; padding: 12px 14px; margin-bottom: 16px;
346
- display: flex; flex-direction: column; gap: 6px;
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-row { display: flex; gap: 10px; align-items: center; }
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
- border-radius: 8px; padding: 10px 14px; font-size: 0.8125rem;
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-banner-inner { padding: 8px 16px; font-size: 11px; flex-wrap: wrap; }
586
+ .cpub-fed-indicator { padding: 6px 16px; font-size: 11px; flex-wrap: wrap; }
381
587
  .cpub-compose-bar { padding: 10px 12px; }
382
- .cpub-compose-input { padding: 8px 10px; font-size: 13px; }
383
- .cpub-sidebar-stats { gap: 12px; }
588
+ .cpub-shared-grid { grid-template-columns: 1fr; }
589
+ .cpub-fed-members-info { padding: 24px 16px; }
384
590
  }
385
591
  </style>