@commonpub/layer 0.7.12 → 0.7.13

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.
@@ -128,6 +128,12 @@ function removeCover(): void {
128
128
  updateMeta('coverImageUrl', '');
129
129
  }
130
130
 
131
+ // --- SEO preview ---
132
+ const seoDomain = computed(() => {
133
+ try { return new URL(useRequestURL().origin).hostname; } catch { return 'example.com'; }
134
+ });
135
+ const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
136
+
131
137
  // --- Right panel ---
132
138
  const openSections = ref<Record<string, boolean>>({
133
139
  content: true, seo: false, publishing: true, cover: false,
@@ -333,11 +339,19 @@ const canvasMaxWidth = computed(() => {
333
339
  </EditorSection>
334
340
 
335
341
  <!-- SEO -->
336
- <EditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
337
- <div class="cpub-ep-field">
338
- <label class="cpub-ep-flabel">Meta Description</label>
342
+ <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
343
+ <div class="cpub-seo-card">
344
+ <div class="cpub-seo-url">
345
+ <span class="cpub-seo-favicon">C</span>
346
+ {{ seoDomain }} &rsaquo; article
347
+ </div>
348
+ <div class="cpub-seo-title">{{ (metadata.title as string) || 'Article title' }}</div>
349
+ <div class="cpub-seo-desc">{{ seoPreviewDesc || 'Post description will appear here...' }}</div>
350
+ </div>
351
+ <div class="cpub-ep-field" style="margin-top: 10px;">
352
+ <label class="cpub-ep-flabel">SEO Description</label>
339
353
  <textarea class="cpub-ep-textarea" rows="3" :value="metadata.seoDescription as string" placeholder="Search engine description..." @input="updateMeta('seoDescription', ($event.target as HTMLTextAreaElement).value)" />
340
- <span class="cpub-ep-hint">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
354
+ <span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
341
355
  </div>
342
356
  </EditorSection>
343
357
 
@@ -541,4 +555,23 @@ const canvasMaxWidth = computed(() => {
541
555
  .cpub-ae-cover-overlay,
542
556
  .cpub-ae-cover-actions { opacity: 1; }
543
557
  }
558
+
559
+ /* SEO preview card */
560
+ .cpub-seo-card {
561
+ background: var(--surface2);
562
+ border: var(--border-width-default) solid var(--border);
563
+ padding: 14px;
564
+ }
565
+ .cpub-seo-url {
566
+ font-size: 11px; color: var(--text-faint); margin-bottom: 4px;
567
+ display: flex; align-items: center; gap: 6px;
568
+ }
569
+ .cpub-seo-favicon {
570
+ width: 16px; height: 16px; border-radius: 50%;
571
+ background: var(--accent-bg); color: var(--accent);
572
+ display: flex; align-items: center; justify-content: center;
573
+ font-size: 9px; font-weight: 700;
574
+ }
575
+ .cpub-seo-title { font-size: 14px; color: var(--accent); font-weight: 500; margin-bottom: 2px; }
576
+ .cpub-seo-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
544
577
  </style>
@@ -107,6 +107,9 @@ const authorInitials = computed(() => {
107
107
  const authorUsername = computed(() => user.value?.username || '');
108
108
 
109
109
  // --- SEO preview ---
110
+ const siteDomain = computed(() => {
111
+ try { return new URL(useRequestURL().origin).hostname; } catch { return 'example.com'; }
112
+ });
110
113
  const seoDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
111
114
 
112
115
  // --- Schedule ---
@@ -283,7 +286,7 @@ const canvasMaxWidth = computed(() => {
283
286
  <div class="cpub-be-seo-card">
284
287
  <div class="cpub-be-seo-url">
285
288
  <span class="cpub-be-seo-favicon">C</span>
286
- commonpub.io &rsaquo; {{ authorUsername ? `@${authorUsername}` : 'blog' }}
289
+ {{ siteDomain }} &rsaquo; {{ authorUsername ? `@${authorUsername}` : 'blog' }}
287
290
  </div>
288
291
  <div class="cpub-be-seo-title">{{ (metadata.title as string) || 'Post title' }}</div>
289
292
  <div class="cpub-be-seo-desc">{{ seoDesc || 'Post description will appear here...' }}</div>
@@ -45,6 +45,12 @@ const blockTypes: BlockTypeGroup[] = [
45
45
  },
46
46
  ];
47
47
 
48
+ // --- SEO preview ---
49
+ const seoDomain = computed(() => {
50
+ try { return new URL(useRequestURL().origin).hostname; } catch { return 'example.com'; }
51
+ });
52
+ const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
53
+
48
54
  const openSections = ref<Record<string, boolean>>({
49
55
  meta: true, tags: true, visibility: true, cover: false, seo: false, checklist: true,
50
56
  });
@@ -283,11 +289,19 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
283
289
  </div>
284
290
  </EditorSection>
285
291
 
286
- <EditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
287
- <div class="cpub-pe-field">
288
- <label class="cpub-pe-flabel">Meta Description</label>
292
+ <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
293
+ <div class="cpub-seo-card">
294
+ <div class="cpub-seo-url">
295
+ <span class="cpub-seo-favicon">C</span>
296
+ {{ seoDomain }} &rsaquo; project
297
+ </div>
298
+ <div class="cpub-seo-title">{{ (metadata.title as string) || 'Project title' }}</div>
299
+ <div class="cpub-seo-desc">{{ seoPreviewDesc || 'Post description will appear here...' }}</div>
300
+ </div>
301
+ <div class="cpub-pe-field" style="margin-top: 10px;">
302
+ <label class="cpub-pe-flabel">SEO Description</label>
289
303
  <textarea class="cpub-pe-textarea" rows="3" :value="metadata.seoDescription as string" placeholder="Search engine description (recommended 50-160 chars)" @input="updateMeta('seoDescription', ($event.target as HTMLTextAreaElement).value)" />
290
- <span class="cpub-pe-hint">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
304
+ <span class="cpub-pe-hint cpub-pe-hint-right">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
291
305
  </div>
292
306
  </EditorSection>
293
307
 
@@ -450,4 +464,23 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
450
464
  .cpub-pe-cover-overlay,
451
465
  .cpub-pe-cover-actions { opacity: 1; }
452
466
  }
467
+
468
+ /* SEO preview card */
469
+ .cpub-seo-card {
470
+ background: var(--surface2);
471
+ border: var(--border-width-default) solid var(--border);
472
+ padding: 14px;
473
+ }
474
+ .cpub-seo-url {
475
+ font-size: 11px; color: var(--text-faint); margin-bottom: 4px;
476
+ display: flex; align-items: center; gap: 6px;
477
+ }
478
+ .cpub-seo-favicon {
479
+ width: 16px; height: 16px; border-radius: 50%;
480
+ background: var(--accent-bg); color: var(--accent);
481
+ display: flex; align-items: center; justify-content: center;
482
+ font-size: 9px; font-weight: 700;
483
+ }
484
+ .cpub-seo-title { font-size: 14px; color: var(--accent); font-weight: 500; margin-bottom: 2px; }
485
+ .cpub-seo-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
453
486
  </style>
@@ -81,9 +81,10 @@ useJsonLd({
81
81
 
82
82
  <template>
83
83
  <div class="cpub-article-view">
84
- <!-- COVER IMAGE -->
84
+ <!-- HERO BANNER (per-content bannerUrl → author bannerUrl → pattern fallback) -->
85
85
  <div class="cpub-cover">
86
- <img v-if="content.coverImageUrl" :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-img" />
86
+ <img v-if="content.bannerUrl" :src="content.bannerUrl" :alt="content.title" class="cpub-cover-img" />
87
+ <img v-else-if="content.author?.bannerUrl" :src="content.author.bannerUrl" alt="" class="cpub-cover-img" />
87
88
  <template v-else>
88
89
  <div class="cpub-cover-label">
89
90
  <i class="fa-solid fa-microchip"></i>
@@ -151,6 +152,11 @@ useJsonLd({
151
152
  </aside>
152
153
  </div>
153
154
 
155
+ <!-- COVER PHOTO (in-body) -->
156
+ <div v-if="content.coverImageUrl" class="cpub-cover-photo">
157
+ <img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-photo-img" />
158
+ </div>
159
+
154
160
  <!-- ARTICLE BODY (PROSE) -->
155
161
  <div class="cpub-prose">
156
162
  <template v-if="content.content && Array.isArray(content.content) && (content.content as unknown[]).length > 0">
@@ -765,6 +771,19 @@ useJsonLd({
765
771
  gap: 6px;
766
772
  }
767
773
 
774
+ /* ── COVER PHOTO (in-body) ── */
775
+ .cpub-cover-photo {
776
+ margin-bottom: 24px;
777
+ border: var(--border-width-default) solid var(--border);
778
+ overflow: hidden;
779
+ }
780
+ .cpub-cover-photo-img {
781
+ width: 100%;
782
+ display: block;
783
+ max-height: 420px;
784
+ object-fit: cover;
785
+ }
786
+
768
787
  /* ── RESPONSIVE ── */
769
788
  @media (max-width: 768px) {
770
789
  .cpub-article-wrap { padding: 24px 16px 48px; }
@@ -44,9 +44,9 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
44
44
  <template>
45
45
  <div class="cpub-blog-view">
46
46
 
47
- <!-- COVER IMAGE -->
48
- <div v-if="content.coverImageUrl" class="cpub-cover">
49
- <img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-img" />
47
+ <!-- HERO BANNER -->
48
+ <div v-if="content.bannerUrl || content.author?.bannerUrl" class="cpub-cover">
49
+ <img :src="(content.bannerUrl || content.author?.bannerUrl)!" :alt="content.title" class="cpub-cover-img" />
50
50
  </div>
51
51
 
52
52
  <div class="cpub-blog-wrap">
@@ -96,6 +96,11 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
96
96
  <button class="cpub-eng-btn" aria-label="More options"><i class="fa-solid fa-ellipsis"></i></button>
97
97
  </div>
98
98
 
99
+ <!-- COVER PHOTO (in-body) -->
100
+ <div v-if="content.coverImageUrl" class="cpub-cover-photo">
101
+ <img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-photo-img" />
102
+ </div>
103
+
99
104
  <!-- BLOG BODY (PROSE) -->
100
105
  <div class="cpub-prose">
101
106
  <template v-if="content.content && Array.isArray(content.content) && (content.content as unknown[]).length > 0">
@@ -326,6 +331,19 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
326
331
  background: var(--teal-bg);
327
332
  }
328
333
 
334
+ /* ── COVER PHOTO (in-body) ── */
335
+ .cpub-cover-photo {
336
+ margin-bottom: 24px;
337
+ border: var(--border-width-default) solid var(--border);
338
+ overflow: hidden;
339
+ }
340
+ .cpub-cover-photo-img {
341
+ width: 100%;
342
+ display: block;
343
+ max-height: 420px;
344
+ object-fit: cover;
345
+ }
346
+
329
347
  /* ── ENGAGEMENT ROW ── */
330
348
  .cpub-engagement-row {
331
349
  display: flex;
@@ -300,9 +300,10 @@ async function handleBuild(): Promise<void> {
300
300
 
301
301
  <template>
302
302
  <div class="cpub-project-view">
303
- <!-- HERO COVER -->
304
- <div class="cpub-hero-cover" :class="{ 'cpub-hero-cover-has-image': content.coverImageUrl }">
305
- <img v-if="content.coverImageUrl" :src="content.coverImageUrl" :alt="content.title" class="cpub-hero-cover-img" />
303
+ <!-- HERO BANNER (per-content bannerUrl → author bannerUrl → pattern fallback) -->
304
+ <div class="cpub-hero-cover" :class="{ 'cpub-hero-cover-has-image': content.bannerUrl || content.author?.bannerUrl }">
305
+ <img v-if="content.bannerUrl" :src="content.bannerUrl" :alt="content.title" class="cpub-hero-cover-img" />
306
+ <img v-else-if="content.author?.bannerUrl" :src="content.author.bannerUrl" alt="" class="cpub-hero-cover-img" />
306
307
  <template v-else>
307
308
  <div class="cpub-hero-cover-grid"></div>
308
309
  <div class="cpub-hero-circuit">
@@ -424,6 +425,11 @@ async function handleBuild(): Promise<void> {
424
425
  <div class="cpub-content-col">
425
426
  <!-- OVERVIEW TAB -->
426
427
  <template v-if="activeTab === 'overview'">
428
+ <!-- Cover photo (in-body featured image) -->
429
+ <div v-if="content.coverImageUrl" class="cpub-cover-photo">
430
+ <img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-photo-img" />
431
+ </div>
432
+
427
433
  <div class="cpub-prose">
428
434
  <template v-if="content.content && Array.isArray(content.content) && (content.content as unknown[]).length > 0">
429
435
  <BlocksBlockContentRenderer :blocks="(content.content as [string, Record<string, unknown>][])" />
@@ -923,6 +929,20 @@ async function handleBuild(): Promise<void> {
923
929
  grid-template-columns: 200px 1fr 260px;
924
930
  }
925
931
 
932
+ /* ── COVER PHOTO (in-body) ── */
933
+ .cpub-cover-photo {
934
+ margin-bottom: 24px;
935
+ border: var(--border-width-default) solid var(--border);
936
+ overflow: hidden;
937
+ }
938
+
939
+ .cpub-cover-photo-img {
940
+ width: 100%;
941
+ display: block;
942
+ max-height: 420px;
943
+ object-fit: cover;
944
+ }
945
+
926
946
  /* ── PROSE ── */
927
947
  .cpub-prose {
928
948
  font-size: 13px;
@@ -11,6 +11,7 @@ export interface ContentViewData {
11
11
  description: string | null;
12
12
  content: unknown;
13
13
  coverImageUrl: string | null;
14
+ bannerUrl?: string | null;
14
15
  category: string | null;
15
16
  difficulty: string | null;
16
17
  buildTime: string | null;
@@ -59,6 +60,7 @@ export interface ContentViewData {
59
60
  profileUrl?: string | null;
60
61
  bio?: string | null;
61
62
  headline?: string | null;
63
+ bannerUrl?: string | null;
62
64
  verified?: boolean;
63
65
  org?: string;
64
66
  articleCount?: number;
@@ -2,7 +2,7 @@
2
2
  const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
3
3
  const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
4
4
  const { count: unreadMessages, connect: connectMessages, disconnect: disconnectMessages } = useMessages();
5
- const { hubs, learning, video, docs, contests, admin, federation } = useFeatures();
5
+ const { hubs, learning, video, docs, contests, admin, federation, explainers } = useFeatures();
6
6
  const { isDark, setDarkMode } = useTheme();
7
7
  const { enabledTypeMeta } = useContentTypes();
8
8
  const runtimeConfig = useRuntimeConfig();
@@ -16,6 +16,15 @@ useHead({
16
16
 
17
17
  const userMenuOpen = ref(false);
18
18
  const mobileMenuOpen = ref(false);
19
+ const openDropdown = ref<string | null>(null);
20
+
21
+ function toggleDropdown(name: string): void {
22
+ openDropdown.value = openDropdown.value === name ? null : name;
23
+ }
24
+
25
+ function closeDropdowns(): void {
26
+ openDropdown.value = null;
27
+ }
19
28
 
20
29
  // Cmd+K / Ctrl+K → search
21
30
  function handleGlobalKeydown(e: KeyboardEvent): void {
@@ -29,6 +38,7 @@ function handleGlobalKeydown(e: KeyboardEvent): void {
29
38
  function handleClickOutside(e: MouseEvent): void {
30
39
  const target = e.target as HTMLElement;
31
40
  if (!target.closest('.cpub-user-menu-wrapper')) userMenuOpen.value = false;
41
+ if (!target.closest('.cpub-nav-dropdown')) openDropdown.value = null;
32
42
  }
33
43
 
34
44
  onMounted(async () => {
@@ -72,11 +82,54 @@ const userUsername = computed(() => user.value?.username ?? '');
72
82
 
73
83
  <nav class="cpub-topbar-nav" aria-label="Main navigation">
74
84
  <NuxtLink to="/" class="cpub-nav-link"><i class="fa-solid fa-house"></i> Home</NuxtLink>
85
+
86
+ <!-- Learn dropdown -->
87
+ <div v-if="learning || docs" class="cpub-nav-dropdown">
88
+ <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'learn' }" @click.stop="toggleDropdown('learn')">
89
+ <i class="fa-solid fa-graduation-cap"></i> Learn <i class="fa-solid fa-chevron-down cpub-nav-caret" />
90
+ </button>
91
+ <div v-if="openDropdown === 'learn'" class="cpub-nav-panel">
92
+ <NuxtLink v-if="learning" to="/learn" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
93
+ <NuxtLink v-if="explainers" to="/explainer" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
94
+ <NuxtLink v-if="docs" to="/docs" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Build dropdown -->
99
+ <div class="cpub-nav-dropdown">
100
+ <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'build' }" @click.stop="toggleDropdown('build')">
101
+ <i class="fa-solid fa-hammer"></i> Build <i class="fa-solid fa-chevron-down cpub-nav-caret" />
102
+ </button>
103
+ <div v-if="openDropdown === 'build'" class="cpub-nav-panel">
104
+ <NuxtLink to="/project" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
105
+ <NuxtLink v-if="contests" to="/contests" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- Read dropdown -->
110
+ <div class="cpub-nav-dropdown">
111
+ <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'read' }" @click.stop="toggleDropdown('read')">
112
+ <i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
113
+ </button>
114
+ <div v-if="openDropdown === 'read'" class="cpub-nav-panel">
115
+ <NuxtLink to="/article" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
116
+ <NuxtLink to="/blog" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
117
+ </div>
118
+ </div>
119
+
120
+ <!-- Watch dropdown -->
121
+ <div v-if="video" class="cpub-nav-dropdown">
122
+ <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'watch' }" @click.stop="toggleDropdown('watch')">
123
+ <i class="fa-solid fa-play"></i> Watch <i class="fa-solid fa-chevron-down cpub-nav-caret" />
124
+ </button>
125
+ <div v-if="openDropdown === 'watch'" class="cpub-nav-panel">
126
+ <NuxtLink to="/videos" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
127
+ <span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
128
+ <span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
129
+ </div>
130
+ </div>
131
+
75
132
  <NuxtLink v-if="hubs" to="/hubs" class="cpub-nav-link"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
76
- <NuxtLink v-if="learning" to="/learn" class="cpub-nav-link"><i class="fa-solid fa-graduation-cap"></i> Learn</NuxtLink>
77
- <NuxtLink v-if="video" to="/videos" class="cpub-nav-link"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
78
- <NuxtLink v-if="docs" to="/docs" class="cpub-nav-link"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
79
- <NuxtLink v-if="contests" to="/contests" class="cpub-nav-link"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
80
133
  <NuxtLink v-if="federation" to="/federation" class="cpub-nav-link"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
81
134
  <NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-nav-link"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
82
135
  </nav>
@@ -133,11 +186,35 @@ const userUsername = computed(() => user.value?.username ?? '');
133
186
  <div v-if="mobileMenuOpen" class="cpub-mobile-menu" @click.self="mobileMenuOpen = false">
134
187
  <nav class="cpub-mobile-nav" aria-label="Mobile navigation">
135
188
  <NuxtLink to="/" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-house"></i> Home</NuxtLink>
189
+
190
+ <!-- Learn -->
191
+ <template v-if="learning || docs">
192
+ <div class="cpub-mobile-section-label">Learn</div>
193
+ <NuxtLink v-if="learning" to="/learn" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
194
+ <NuxtLink v-if="explainers" to="/explainer" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
195
+ <NuxtLink v-if="docs" to="/docs" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
196
+ </template>
197
+
198
+ <!-- Build -->
199
+ <div class="cpub-mobile-section-label">Build</div>
200
+ <NuxtLink to="/project" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
201
+ <NuxtLink v-if="contests" to="/contests" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
202
+
203
+ <!-- Read -->
204
+ <div class="cpub-mobile-section-label">Read</div>
205
+ <NuxtLink to="/article" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
206
+ <NuxtLink to="/blog" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
207
+
208
+ <!-- Watch -->
209
+ <template v-if="video">
210
+ <div class="cpub-mobile-section-label">Watch</div>
211
+ <NuxtLink to="/videos" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
212
+ <span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
213
+ <span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
214
+ </template>
215
+
216
+ <div class="cpub-mobile-divider" />
136
217
  <NuxtLink v-if="hubs" to="/hubs" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
137
- <NuxtLink v-if="learning" to="/learn" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-graduation-cap"></i> Learn</NuxtLink>
138
- <NuxtLink v-if="video" to="/videos" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
139
- <NuxtLink v-if="docs" to="/docs" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
140
- <NuxtLink v-if="contests" to="/contests" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
141
218
  <NuxtLink v-if="federation" to="/federation" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
142
219
  <NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
143
220
  <NuxtLink to="/search" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-magnifying-glass"></i> Search</NuxtLink>
@@ -220,6 +297,37 @@ const userUsername = computed(() => user.value?.username ?? '');
220
297
  .cpub-nav-link:hover { color: var(--text); background: var(--surface2); }
221
298
  .cpub-nav-link.router-link-active { color: var(--text); background: var(--surface2); border-color: var(--border); }
222
299
 
300
+ /* Nav dropdowns */
301
+ .cpub-nav-dropdown { position: relative; }
302
+ .cpub-nav-trigger { cursor: pointer; }
303
+ .cpub-nav-caret { font-size: 7px !important; margin-left: 2px; transition: transform 0.15s; }
304
+ .cpub-nav-trigger--open .cpub-nav-caret { transform: rotate(180deg); }
305
+ .cpub-nav-panel {
306
+ position: absolute; top: 100%; left: 0; min-width: 180px;
307
+ background: var(--surface); border: var(--border-width-default) solid var(--border);
308
+ box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0;
309
+ margin-top: 4px;
310
+ }
311
+ .cpub-nav-panel-item {
312
+ display: flex; align-items: center; gap: 8px; padding: 8px 14px;
313
+ font-size: 12px; color: var(--text-dim); text-decoration: none;
314
+ transition: background 0.1s, color 0.1s; cursor: pointer;
315
+ }
316
+ .cpub-nav-panel-item:hover { background: var(--surface2); color: var(--text); }
317
+ .cpub-nav-panel-item i { width: 14px; text-align: center; font-size: 11px; }
318
+ .cpub-nav-panel-item--disabled {
319
+ opacity: 0.35; cursor: not-allowed; pointer-events: none;
320
+ }
321
+
322
+ /* Mobile nav sections */
323
+ .cpub-mobile-section-label {
324
+ font-family: var(--font-mono); font-size: 9px; font-weight: 700;
325
+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint);
326
+ padding: 10px 20px 2px; margin-top: 4px;
327
+ }
328
+ .cpub-mobile-link--indent { padding-left: 36px; }
329
+ .cpub-mobile-link--disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
330
+
223
331
  .cpub-topbar-spacer { flex: 1; }
224
332
  .cpub-topbar-actions { display: flex; align-items: center; gap: 6px; }
225
333
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
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/config": "0.9.0",
54
53
  "@commonpub/auth": "0.5.0",
55
- "@commonpub/editor": "0.7.4",
56
54
  "@commonpub/docs": "0.6.2",
55
+ "@commonpub/config": "0.9.0",
57
56
  "@commonpub/explainer": "0.7.6",
58
- "@commonpub/learning": "0.5.0",
59
- "@commonpub/schema": "0.9.4",
57
+ "@commonpub/editor": "0.7.4",
58
+ "@commonpub/schema": "0.9.5",
59
+ "@commonpub/server": "2.27.7",
60
60
  "@commonpub/protocol": "0.9.7",
61
- "@commonpub/ui": "0.8.5",
62
- "@commonpub/server": "2.27.6"
61
+ "@commonpub/learning": "0.5.0",
62
+ "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",