@commonpub/layer 0.21.2 → 0.21.4

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.
@@ -44,7 +44,13 @@ const hasChildren = computed(() => children.value.length > 0);
44
44
  <span v-if="time" class="cpub-step-time"><i class="fa-regular fa-clock"></i> {{ time }}</span>
45
45
  </div>
46
46
  <div v-if="hasChildren" class="cpub-step-body">
47
- <BlockContentRenderer :blocks="children" />
47
+ <!-- Nuxt pathPrefix auto-imports components/blocks/BlockContentRenderer.vue
48
+ under the name BlocksBlockContentRenderer (the Blocks dir prefix only
49
+ de-dups when the filename starts with it). The bare tag would not
50
+ resolve and would silently blank nested step content. A static import
51
+ is not an option here: BlockContentRenderer and BlockBuildStepView are
52
+ mutually recursive (ESM cycle). Use the auto-import name. -->
53
+ <BlocksBlockContentRenderer :blocks="children" />
48
54
  </div>
49
55
  </div>
50
56
  </template>
@@ -74,7 +74,7 @@ function dismissHero(): void {
74
74
  .cpub-hero-dismiss { position: absolute; top: 12px; right: 16px; background: transparent; border: none; color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 4px; z-index: 2; }
75
75
  .cpub-hero-dismiss:hover { color: var(--text-dim); }
76
76
  .cpub-hero-inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 36px 32px; width: 100%; display: flex; align-items: center; gap: 48px; }
77
- .cpub-hero-content { flex: 1; }
77
+ .cpub-hero-content { flex: 1; min-width: 0; }
78
78
  .cpub-hero-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
79
79
  .cpub-hero-badge { font-size: 9px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 3px 9px; background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); color: var(--yellow); }
80
80
  .cpub-hero-badge-live { background: var(--green-bg); border-color: var(--green); color: var(--green); display: flex; align-items: center; gap: 5px; }
@@ -83,5 +83,13 @@ function dismissHero(): void {
83
83
  .cpub-hero-title { font-size: 22px; font-weight: 700; line-height: 1.25; margin-bottom: 10px; }
84
84
  .cpub-hero-title span { color: var(--accent); }
85
85
  .cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
86
- .cpub-hero-actions { display: flex; gap: 8px; }
86
+ .cpub-hero-actions { display: flex; flex-wrap: wrap; gap: 8px; }
87
+
88
+ @media (max-width: 640px) {
89
+ .cpub-hero-inner { flex-direction: column; align-items: flex-start; gap: 20px; padding: 24px 16px; }
90
+ .cpub-hero-title { font-size: 19px; }
91
+ .cpub-hero-excerpt { font-size: 13px; }
92
+ .cpub-hero-actions { width: 100%; }
93
+ .cpub-hero-actions :deep(.cpub-btn) { flex: 1 1 140px; justify-content: center; }
94
+ }
87
95
  </style>
@@ -1,12 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import type { HomepageSection } from '@commonpub/server';
3
3
 
4
- defineProps<{
4
+ const props = defineProps<{
5
5
  sections: HomepageSection[];
6
6
  /** Which zone to render: 'main' for feed column, 'sidebar' for sidebar */
7
7
  zone: 'main' | 'sidebar' | 'full-width';
8
+ /** If set, only render sections whose type is in this list (zone still applies). */
9
+ restrictTypes?: string[];
10
+ /** If set, skip sections whose type is in this list. */
11
+ excludeTypes?: string[];
8
12
  }>();
9
13
 
14
+ function typeAllowed(type: string): boolean {
15
+ if (props.restrictTypes && !props.restrictTypes.includes(type)) return false;
16
+ if (props.excludeTypes && props.excludeTypes.includes(type)) return false;
17
+ return true;
18
+ }
19
+
10
20
  const features = useFeatures();
11
21
 
12
22
  function isFeatureEnabled(featureGate?: string): boolean {
@@ -29,7 +39,7 @@ function sectionZone(section: HomepageSection): 'full-width' | 'main' | 'sidebar
29
39
 
30
40
  <template>
31
41
  <template v-for="section in sections" :key="section.id">
32
- <template v-if="section.enabled && sectionZone(section) === zone && isFeatureEnabled(section.config.featureGate)">
42
+ <template v-if="section.enabled && sectionZone(section) === zone && typeAllowed(section.type) && isFeatureEnabled(section.config.featureGate)">
33
43
  <HomepageHeroSection
34
44
  v-if="section.type === 'hero'"
35
45
  :config="section.config"
@@ -509,7 +509,10 @@ async function handleBuild(): Promise<void> {
509
509
  <span v-if="step.time" class="cpub-build-step-time"><i class="fa-regular fa-clock"></i> {{ step.time }}</span>
510
510
  </div>
511
511
  <div v-if="step.children.length > 0" class="cpub-build-step-body">
512
- <BlockContentRenderer :blocks="step.children" />
512
+ <!-- Auto-import name (same as the body renderer above); the
513
+ bare BlockContentRenderer tag does not resolve under
514
+ Nuxt pathPrefix and silently blanks nested content. -->
515
+ <BlocksBlockContentRenderer :blocks="step.children" />
513
516
  </div>
514
517
  </div>
515
518
  </div>
@@ -1,5 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import type { NavItem } from '@commonpub/server';
3
+ // Explicit import: Nuxt's pathPrefix auto-import names this component
4
+ // `<NavMobileNavRenderer>` (the `nav/` dir prefix only de-duplicates when
5
+ // the filename starts with `Nav`, which `MobileNavRenderer` does not).
6
+ // Referencing `<MobileNavRenderer>` below would otherwise silently fail to
7
+ // resolve, leaving the mobile hamburger menu empty.
8
+ import MobileNavRenderer from '../components/nav/MobileNavRenderer.vue';
3
9
 
4
10
  const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
5
11
  const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
@@ -104,18 +110,22 @@ const userUsername = computed(() => user.value?.username ?? '');
104
110
  <div class="cpub-topbar-spacer" />
105
111
 
106
112
  <div class="cpub-topbar-actions">
107
- <NuxtLink to="/search" class="cpub-search-btn" aria-label="Search">
113
+ <!-- Search/messages/notifications are desktop-only in the top bar.
114
+ On mobile they live in the hamburger menu (search) and the
115
+ avatar dropdown (messages/notifications) so the bar can't
116
+ overflow and hide the hamburger toggle. -->
117
+ <NuxtLink to="/search" class="cpub-search-btn cpub-topbar-desktop-only" aria-label="Search">
108
118
  <i class="fa-solid fa-magnifying-glass"></i>
109
119
  <span class="cpub-search-text">Search...</span>
110
120
  <span class="cpub-kbd">&lceil;K</span>
111
121
  </NuxtLink>
112
122
 
113
123
  <template v-if="isAuthenticated">
114
- <NuxtLink to="/messages" class="cpub-icon-btn" title="Messages" aria-label="Messages">
124
+ <NuxtLink to="/messages" class="cpub-icon-btn cpub-topbar-desktop-only" title="Messages" aria-label="Messages">
115
125
  <i class="fa-solid fa-envelope"></i>
116
126
  <span v-if="unreadMessages > 0" class="cpub-notif-badge" aria-label="unread messages">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
117
127
  </NuxtLink>
118
- <NuxtLink to="/notifications" class="cpub-icon-btn" title="Notifications" aria-label="Notifications">
128
+ <NuxtLink to="/notifications" class="cpub-icon-btn cpub-topbar-desktop-only" title="Notifications" aria-label="Notifications">
119
129
  <i class="fa-solid fa-bell"></i>
120
130
  <span v-if="unreadCount > 0" class="cpub-notif-badge" aria-label="unread notifications">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
121
131
  </NuxtLink>
@@ -130,6 +140,17 @@ const userUsername = computed(() => user.value?.username ?? '');
130
140
  </span>
131
141
  </button>
132
142
  <div v-if="userMenuOpen" class="cpub-user-dropdown" role="menu">
143
+ <!-- Mobile-only: messages/notifications relocated here from
144
+ the top bar (hidden on desktop, which keeps the icons). -->
145
+ <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
146
+ <i class="fa-solid fa-envelope"></i> Messages
147
+ <span v-if="unreadMessages > 0" class="cpub-dropdown-count">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
148
+ </NuxtLink>
149
+ <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
150
+ <i class="fa-solid fa-bell"></i> Notifications
151
+ <span v-if="unreadCount > 0" class="cpub-dropdown-count">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
152
+ </NuxtLink>
153
+ <div class="cpub-dropdown-divider cpub-dropdown-item--mobile" />
133
154
  <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
134
155
  <NuxtLink to="/dashboard" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
135
156
  <NuxtLink to="/settings" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
@@ -162,8 +183,7 @@ const userUsername = computed(() => user.value?.username ?? '');
162
183
  <div class="cpub-mobile-divider" />
163
184
  <NuxtLink to="/create" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-plus"></i> Create</NuxtLink>
164
185
  <NuxtLink to="/dashboard" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
165
- <NuxtLink to="/messages" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-envelope"></i> Messages</NuxtLink>
166
- <NuxtLink to="/notifications" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-bell"></i> Notifications</NuxtLink>
186
+ <!-- Messages/Notifications live in the avatar dropdown on mobile. -->
167
187
  </template>
168
188
  </div>
169
189
  </div>
@@ -317,6 +337,10 @@ const userUsername = computed(() => user.value?.username ?? '');
317
337
  .cpub-dropdown-item:hover { background: var(--surface2); color: var(--text); }
318
338
  .cpub-dropdown-item i { width: 14px; text-align: center; font-size: 11px; }
319
339
  .cpub-dropdown-divider { height: 2px; background: var(--border2); margin: 4px 12px; }
340
+ .cpub-dropdown-count { margin-left: auto; min-width: 18px; height: 16px; padding: 0 5px; border-radius: 8px; background: var(--accent); color: var(--color-text-inverse); font-size: 9px; font-weight: 700; font-family: var(--font-mono); line-height: 16px; text-align: center; }
341
+ /* Messages/Notifications in the avatar dropdown are mobile-only —
342
+ desktop keeps the dedicated top-bar icons. */
343
+ .cpub-dropdown-item--mobile { display: none; }
320
344
 
321
345
  .cpub-mobile-toggle { display: none; width: 32px; height: 32px; background: none; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; align-items: center; justify-content: center; }
322
346
  .cpub-mobile-menu { display: none; position: fixed; inset: 0; top: 48px; z-index: 99; background: var(--color-surface-overlay-light); }
@@ -346,7 +370,10 @@ const userUsername = computed(() => user.value?.username ?? '');
346
370
 
347
371
  @media (max-width: 768px) {
348
372
  :deep(.cpub-topbar-nav) { display: none; }
349
- .cpub-search-btn { min-width: auto; padding: 6px 8px; }
373
+ /* Search / messages / notifications move off the top bar on mobile so
374
+ the row can't overflow and clip the hamburger + avatar. */
375
+ .cpub-topbar-desktop-only { display: none !important; }
376
+ .cpub-dropdown-item--mobile { display: flex; }
350
377
  .cpub-search-text, .cpub-kbd, .cpub-new-text { display: none; }
351
378
  .cpub-mobile-toggle { display: flex; }
352
379
  .cpub-mobile-menu { display: block; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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.12.0",
54
53
  "@commonpub/editor": "0.7.9",
54
+ "@commonpub/protocol": "0.9.10",
55
+ "@commonpub/schema": "0.16.0",
56
+ "@commonpub/docs": "0.6.3",
57
+ "@commonpub/auth": "0.6.0",
55
58
  "@commonpub/explainer": "0.7.12",
56
59
  "@commonpub/learning": "0.5.2",
57
- "@commonpub/server": "2.51.0",
58
- "@commonpub/auth": "0.6.0",
59
60
  "@commonpub/ui": "0.8.5",
60
- "@commonpub/protocol": "0.9.9",
61
- "@commonpub/schema": "0.16.0",
62
- "@commonpub/docs": "0.6.2"
61
+ "@commonpub/config": "0.12.0",
62
+ "@commonpub/server": "2.53.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
package/pages/index.vue CHANGED
@@ -148,13 +148,28 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
148
148
  <!-- Full-width sections (hero) -->
149
149
  <HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
150
150
 
151
+ <!-- Mobile only: hoist the contests section above the feed (contests
152
+ are time-sensitive). Desktop keeps contests in the sidebar; hubs
153
+ and stats stay after the feed on mobile. -->
154
+ <div class="cpub-mobile-contest-hoist">
155
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" :restrict-types="['contests']" />
156
+ </div>
157
+
151
158
  <!-- 2-column layout: main + sidebar -->
152
159
  <div class="cpub-main-layout">
153
160
  <main class="cpub-feed-col">
154
161
  <HomepageSectionRenderer :sections="sortedSections" zone="main" />
155
162
  </main>
156
163
  <aside class="cpub-sidebar">
157
- <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
164
+ <!-- display:contents wrappers — layout-transparent, so the
165
+ sidebar's flex gap is unaffected. Desktop shows the full
166
+ sidebar; mobile shows it minus contests (hoisted above). -->
167
+ <div class="cpub-sidebar-desktop">
168
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
169
+ </div>
170
+ <div class="cpub-sidebar-mobile">
171
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" :exclude-types="['contests']" />
172
+ </div>
158
173
  <!-- Powered badge -->
159
174
  <div class="cpub-powered-badge">
160
175
  <span class="cpub-powered-text">Powered by</span>
@@ -494,7 +509,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
494
509
  gap: 48px;
495
510
  }
496
511
 
497
- .cpub-hero-content { flex: 1; }
512
+ .cpub-hero-content { flex: 1; min-width: 0; }
498
513
 
499
514
  .cpub-hero-eyebrow {
500
515
  display: flex;
@@ -553,7 +568,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
553
568
  max-width: 560px;
554
569
  }
555
570
 
556
- .cpub-hero-actions { display: flex; gap: 8px; }
571
+ .cpub-hero-actions { display: flex; flex-wrap: wrap; gap: 8px; }
557
572
 
558
573
  .cpub-hero-meta {
559
574
  display: flex;
@@ -898,6 +913,17 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
898
913
  gap: 18px;
899
914
  }
900
915
 
916
+ /* Contests hoisted above the feed on mobile only. display:contents keeps
917
+ the sidebar's flex gap intact on desktop (wrapper boxes vanish). */
918
+ .cpub-mobile-contest-hoist { display: none; }
919
+ .cpub-sidebar-desktop { display: contents; }
920
+ .cpub-sidebar-mobile { display: none; }
921
+ @media (max-width: 768px) {
922
+ .cpub-mobile-contest-hoist { display: block; max-width: 1280px; margin: 0 auto; padding: 16px 16px 0; }
923
+ .cpub-sidebar-desktop { display: none; }
924
+ .cpub-sidebar-mobile { display: contents; }
925
+ }
926
+
901
927
  .cpub-sb-head {
902
928
  font-size: 10px;
903
929
  font-family: var(--font-mono);
@@ -1167,6 +1193,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
1167
1193
  .cpub-hero-inner {
1168
1194
  padding: 24px 16px;
1169
1195
  }
1196
+ .cpub-hero-actions { width: 100%; }
1197
+ .cpub-hero-actions .cpub-btn { flex: 1 1 140px; justify-content: center; }
1170
1198
  .cpub-main-layout {
1171
1199
  padding: 16px;
1172
1200
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { createStorageFromEnv, generateStorageKey, validateUpload, ALLOWED_IMAGE_TYPES } from '@commonpub/server';
2
+ import { createStorageFromEnv, generateStorageKey, ALLOWED_IMAGE_TYPES, safeFetchBinary } from '@commonpub/server';
3
3
 
4
4
  const schema = z.object({
5
5
  url: z.string().url(),
@@ -7,62 +7,36 @@ const schema = z.object({
7
7
  });
8
8
 
9
9
  export default defineEventHandler(async (event) => {
10
- const user = requireAuth(event);
10
+ requireAuth(event);
11
11
  const { url, purpose } = await parseBody(event, schema);
12
12
 
13
- // SSRF protection block private/internal IPs
14
- const parsed = new URL(url);
15
- const hostname = parsed.hostname.toLowerCase();
16
- const h = hostname.replace(/^\[|\]$/g, '');
17
- if (
18
- h === 'localhost' ||
19
- h === 'localhost.localdomain' ||
20
- h === 'metadata.google.internal' ||
21
- h.endsWith('.local') ||
22
- /^127\./.test(h) ||
23
- /^10\./.test(h) ||
24
- /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
25
- /^192\.168\./.test(h) ||
26
- /^169\.254\./.test(h) ||
27
- /^0\./.test(h) ||
28
- h === '::1' ||
29
- /^f[cd]/i.test(h) ||
30
- /^fe80/i.test(h)
31
- ) {
32
- throw createError({ statusCode: 400, statusMessage: 'Cannot fetch from private/local addresses' });
33
- }
34
-
35
- // Download the remote image
36
- const controller = new AbortController();
37
- const timeout = setTimeout(() => controller.abort(), 10_000); // 10s timeout
38
-
39
- let response: Response;
13
+ // SSRF-safe fetch: blocks private/reserved/numeric hosts and non-HTTP(S)
14
+ // schemes, re-validates every redirect hop, and enforces a 10 MB streaming
15
+ // cap + deadline. Replaces a hand-rolled denylist that missed redirect
16
+ // re-validation, IPv4-mapped IPv6, and numeric-IP encodings.
17
+ let buffer: Buffer;
18
+ let contentType: string;
40
19
  try {
41
- response = await fetch(url, {
42
- signal: controller.signal,
43
- headers: { 'User-Agent': 'devEco.io Image Fetcher' },
44
- });
20
+ ({ buffer, contentType } = await safeFetchBinary(url, {
21
+ accept: 'image/*',
22
+ userAgent: 'CommonPub/1.0 (image-upload)',
23
+ timeoutMs: 10_000,
24
+ }));
45
25
  } catch (err) {
26
+ const msg = err instanceof Error ? err.message : '';
27
+ if (msg.includes('private or reserved')) {
28
+ throw createError({ statusCode: 400, statusMessage: 'Cannot fetch from private/local addresses' });
29
+ }
30
+ if (msg === 'Response too large') {
31
+ throw createError({ statusCode: 400, statusMessage: 'Image too large (max 10MB)' });
32
+ }
46
33
  throw createError({ statusCode: 400, statusMessage: 'Failed to fetch remote image' });
47
- } finally {
48
- clearTimeout(timeout);
49
34
  }
50
35
 
51
- if (!response.ok) {
52
- throw createError({ statusCode: 400, statusMessage: `Remote server returned ${response.status}` });
53
- }
54
-
55
- const contentType = response.headers.get('content-type') || '';
56
36
  if (![...ALLOWED_IMAGE_TYPES].some((t: string) => contentType.startsWith(t))) {
57
37
  throw createError({ statusCode: 400, statusMessage: `Unsupported image type: ${contentType}` });
58
38
  }
59
39
 
60
- const buffer = Buffer.from(await response.arrayBuffer());
61
- const maxSize = 10 * 1024 * 1024; // 10MB
62
- if (buffer.length > maxSize) {
63
- throw createError({ statusCode: 400, statusMessage: 'Image too large (max 10MB)' });
64
- }
65
-
66
40
  // Upload to storage
67
41
  const storage = createStorageFromEnv();
68
42
  const ext = contentType.split('/')[1] || 'jpg';
@@ -113,6 +113,7 @@ export default defineEventHandler(async (event) => {
113
113
  // Handle auth API routes — skip custom routes that Nitro handles directly
114
114
  const isCustomAuthRoute = pathname.startsWith('/api/auth/federated/')
115
115
  || pathname.startsWith('/api/auth/oauth2/')
116
+ || pathname.startsWith('/api/auth/mastodon/')
116
117
  || pathname === '/api/auth/sign-in-username'
117
118
  || pathname === '/api/auth/delete-user'
118
119
  || pathname === '/api/auth/export-data';