@commonpub/layer 0.7.18 → 0.7.20

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,26 @@ function removeCover(): void {
128
128
  updateMeta('coverImageUrl', '');
129
129
  }
130
130
 
131
+ // --- Banner image ---
132
+ const bannerUrl = computed(() => (props.metadata.bannerUrl as string) || '');
133
+
134
+ function onBannerUpload(event: Event): void {
135
+ const input = event.target as HTMLInputElement;
136
+ if (!input.files?.length) return;
137
+ const file = input.files[0];
138
+ if (!file) return;
139
+ const formData = new FormData();
140
+ formData.append('file', file);
141
+ formData.append('purpose', 'cover');
142
+ $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
143
+ .then((res) => { updateMeta('bannerUrl', res.url); })
144
+ .catch(() => {});
145
+ }
146
+
147
+ function removeBanner(): void {
148
+ updateMeta('bannerUrl', '');
149
+ }
150
+
131
151
  // --- SEO preview ---
132
152
  const seoDomain = computed(() => {
133
153
  try { return new URL(useRequestURL().origin).hostname; } catch { return 'example.com'; }
@@ -136,7 +156,7 @@ const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string)
136
156
 
137
157
  // --- Right panel ---
138
158
  const openSections = ref<Record<string, boolean>>({
139
- content: true, seo: false, publishing: true, cover: false,
159
+ content: true, seo: false, publishing: true, cover: false, banner: false,
140
160
  });
141
161
  function toggleSection(key: string): void {
142
162
  openSections.value[key] = !openSections.value[key];
@@ -347,6 +367,27 @@ const canvasMaxWidth = computed(() => {
347
367
  </div>
348
368
  </EditorSection>
349
369
 
370
+ <!-- Banner Image -->
371
+ <EditorSection title="Banner Image" icon="fa-panorama" :open="openSections.banner" @toggle="toggleSection('banner')">
372
+ <p class="cpub-ep-hint" style="margin-bottom: 8px;">Hero background at the top of the page. Falls back to your profile banner if not set.</p>
373
+ <div v-if="bannerUrl" class="cpub-ae-banner-preview">
374
+ <img :src="bannerUrl" alt="Banner" class="cpub-ae-banner-img" />
375
+ <div class="cpub-ae-banner-actions">
376
+ <button class="cpub-ae-cover-btn" @click="removeBanner"><i class="fa-solid fa-trash"></i> Remove</button>
377
+ <label class="cpub-ae-cover-btn">
378
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Replace
379
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
380
+ </label>
381
+ </div>
382
+ </div>
383
+ <div v-else>
384
+ <label class="cpub-ae-cover-btn primary" style="display: inline-flex;">
385
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Upload Banner
386
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
387
+ </label>
388
+ </div>
389
+ </EditorSection>
390
+
350
391
  <!-- SEO -->
351
392
  <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
352
393
  <div class="cpub-seo-card">
@@ -583,4 +624,9 @@ const canvasMaxWidth = computed(() => {
583
624
  }
584
625
  .cpub-seo-title { font-size: 14px; color: var(--accent); font-weight: 500; margin-bottom: 2px; }
585
626
  .cpub-seo-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
627
+
628
+ /* Banner preview */
629
+ .cpub-ae-banner-preview { position: relative; margin-bottom: 8px; }
630
+ .cpub-ae-banner-img { width: 100%; height: 80px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); }
631
+ .cpub-ae-banner-actions { display: flex; gap: 6px; margin-top: 6px; }
586
632
  </style>
@@ -45,7 +45,7 @@ const blockTypes: BlockTypeGroup[] = [
45
45
  ];
46
46
 
47
47
  const openSections = ref<Record<string, boolean>>({
48
- meta: true, excerpt: true, seo: true, publishing: true, author: true, social: false,
48
+ meta: true, excerpt: true, banner: false, seo: true, publishing: true, author: true, social: false,
49
49
  });
50
50
  function toggleSection(key: string): void {
51
51
  openSections.value[key] = !openSections.value[key];
@@ -80,6 +80,26 @@ function removeCover(): void {
80
80
  updateMeta('coverImageUrl', '');
81
81
  }
82
82
 
83
+ // --- Banner image ---
84
+ const bannerUrl = computed(() => (props.metadata.bannerUrl as string) || '');
85
+
86
+ function onBannerUpload(event: Event): void {
87
+ const input = event.target as HTMLInputElement;
88
+ if (!input.files?.length) return;
89
+ const file = input.files[0];
90
+ if (!file) return;
91
+ const formData = new FormData();
92
+ formData.append('file', file);
93
+ formData.append('purpose', 'cover');
94
+ $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
95
+ .then((res) => { updateMeta('bannerUrl', res.url); })
96
+ .catch(() => {});
97
+ }
98
+
99
+ function removeBanner(): void {
100
+ updateMeta('bannerUrl', '');
101
+ }
102
+
83
103
  // --- Word count ---
84
104
  const wordCount = computed(() => {
85
105
  let count = 0;
@@ -289,6 +309,27 @@ const canvasMaxWidth = computed(() => {
289
309
  </div>
290
310
  </EditorSection>
291
311
 
312
+ <!-- Banner Image -->
313
+ <EditorSection title="Banner Image" icon="fa-panorama" :open="openSections.banner" @toggle="toggleSection('banner')">
314
+ <p class="cpub-ep-hint" style="margin-bottom: 8px;">Hero background at the top of the page. Falls back to your profile banner if not set.</p>
315
+ <div v-if="bannerUrl" class="cpub-be-banner-preview">
316
+ <img :src="bannerUrl" alt="Banner" class="cpub-be-banner-img" />
317
+ <div class="cpub-be-banner-actions">
318
+ <button class="cpub-be-cover-btn" @click="removeBanner"><i class="fa-solid fa-trash"></i> Remove</button>
319
+ <label class="cpub-be-cover-btn">
320
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Replace
321
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
322
+ </label>
323
+ </div>
324
+ </div>
325
+ <div v-else>
326
+ <label class="cpub-be-cover-btn primary" style="display: inline-flex;">
327
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Upload Banner
328
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
329
+ </label>
330
+ </div>
331
+ </EditorSection>
332
+
292
333
  <!-- SEO Preview -->
293
334
  <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
294
335
  <div class="cpub-be-seo-card">
@@ -580,4 +621,9 @@ const canvasMaxWidth = computed(() => {
580
621
  .cpub-be-cover-actions { opacity: 1; }
581
622
  .cpub-be-og-overlay { opacity: 1; }
582
623
  }
624
+
625
+ /* Banner preview */
626
+ .cpub-be-banner-preview { position: relative; margin-bottom: 8px; }
627
+ .cpub-be-banner-img { width: 100%; height: 80px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); }
628
+ .cpub-be-banner-actions { display: flex; gap: 6px; margin-top: 6px; }
583
629
  </style>
@@ -52,7 +52,7 @@ const seoDomain = computed(() => {
52
52
  const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
53
53
 
54
54
  const openSections = ref<Record<string, boolean>>({
55
- meta: true, tags: true, visibility: true, cover: false, seo: false, checklist: true,
55
+ meta: true, tags: true, visibility: true, cover: false, banner: false, seo: false, checklist: true,
56
56
  });
57
57
  function toggleSection(key: string): void {
58
58
  openSections.value[key] = !openSections.value[key];
@@ -85,6 +85,26 @@ function removeCover(): void {
85
85
  updateMeta('coverImageUrl', '');
86
86
  }
87
87
 
88
+ // --- Banner image ---
89
+ const bannerUrl = computed(() => (props.metadata.bannerUrl as string) || '');
90
+
91
+ function onBannerUpload(event: Event): void {
92
+ const input = event.target as HTMLInputElement;
93
+ if (!input.files?.length) return;
94
+ const file = input.files[0];
95
+ if (!file) return;
96
+ const formData = new FormData();
97
+ formData.append('file', file);
98
+ formData.append('purpose', 'cover');
99
+ $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
100
+ .then((res) => { updateMeta('bannerUrl', res.url); })
101
+ .catch(() => {});
102
+ }
103
+
104
+ function removeBanner(): void {
105
+ updateMeta('bannerUrl', '');
106
+ }
107
+
88
108
  const tags = computed(() => (props.metadata.tags as string[]) || []);
89
109
  function onTagsUpdate(newTags: string[]): void { updateMeta('tags', newTags); }
90
110
  const visibility = computed(() => (props.metadata.visibility as string) || 'public');
@@ -297,6 +317,27 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
297
317
  </div>
298
318
  </EditorSection>
299
319
 
320
+ <!-- Banner Image -->
321
+ <EditorSection title="Banner Image" icon="fa-panorama" :open="openSections.banner" @toggle="toggleSection('banner')">
322
+ <p class="cpub-pe-hint" style="margin-bottom: 8px;">Hero background at the top of the page. Falls back to your profile banner if not set.</p>
323
+ <div v-if="bannerUrl" class="cpub-pe-banner-preview">
324
+ <img :src="bannerUrl" alt="Banner" class="cpub-pe-banner-img" />
325
+ <div class="cpub-pe-banner-actions">
326
+ <button class="cpub-pe-cover-btn" @click="removeBanner"><i class="fa-solid fa-trash"></i> Remove</button>
327
+ <label class="cpub-pe-cover-btn">
328
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Replace
329
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
330
+ </label>
331
+ </div>
332
+ </div>
333
+ <div v-else>
334
+ <label class="cpub-pe-cover-btn primary" style="display: inline-flex;">
335
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> Upload Banner
336
+ <input type="file" accept="image/*" class="cpub-sr-only" @change="onBannerUpload">
337
+ </label>
338
+ </div>
339
+ </EditorSection>
340
+
300
341
  <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
301
342
  <div class="cpub-seo-card">
302
343
  <div class="cpub-seo-url">
@@ -497,4 +538,9 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
497
538
  }
498
539
  .cpub-seo-title { font-size: 14px; color: var(--accent); font-weight: 500; margin-bottom: 2px; }
499
540
  .cpub-seo-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
541
+
542
+ /* Banner preview */
543
+ .cpub-pe-banner-preview { position: relative; margin-bottom: 8px; }
544
+ .cpub-pe-banner-img { width: 100%; height: 80px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); }
545
+ .cpub-pe-banner-actions { display: flex; gap: 6px; margin-top: 6px; }
500
546
  </style>
@@ -603,10 +603,18 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
603
603
  .cpub-content-wrap {
604
604
  max-width: 720px;
605
605
  margin: 0 auto;
606
- padding: 44px 36px 80px;
606
+ padding: 44px clamp(12px, 4vw, 36px) 80px;
607
607
  min-height: calc(100vh - 51px - 80px);
608
+ overflow-wrap: break-word;
609
+ word-break: break-word;
608
610
  }
609
611
 
612
+ /* Prevent content overflow */
613
+ .cpub-content-wrap :deep(img),
614
+ .cpub-content-wrap :deep(video),
615
+ .cpub-content-wrap :deep(iframe) { max-width: 100%; height: auto; }
616
+ .cpub-content-wrap :deep(pre) { overflow-x: auto; -webkit-overflow-scrolling: touch; }
617
+
610
618
  /* ── COVER IMAGE ── */
611
619
  .cpub-explainer-cover {
612
620
  width: 100%;
@@ -773,7 +781,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
773
781
  @media (max-width: 768px) {
774
782
  .cpub-explainer-sidebar { display: none; }
775
783
  .cpub-mobile-author { display: flex; }
776
- .cpub-content-wrap { padding: 24px 16px 48px; }
784
+ .cpub-content-wrap { padding-top: 24px; padding-bottom: 48px; }
777
785
  .cpub-section-nav { flex-direction: column; gap: 16px; }
778
786
  .cpub-section-title { font-size: 22px; }
779
787
  .cpub-explainer-topbar { gap: 6px; padding: 0 10px; }
@@ -786,7 +794,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
786
794
  }
787
795
 
788
796
  @media (max-width: 480px) {
789
- .cpub-content-wrap { padding: 16px 12px 40px; }
797
+ .cpub-content-wrap { padding-top: 16px; padding-bottom: 40px; }
790
798
  .cpub-section-title { font-size: 18px; }
791
799
  .cpub-section-body { font-size: 14px; }
792
800
  .cpub-section-num-badge { width: 28px; height: 28px; font-size: 11px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,15 +51,15 @@
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
53
  "@commonpub/editor": "0.7.5",
54
- "@commonpub/config": "0.9.0",
55
54
  "@commonpub/auth": "0.5.0",
56
55
  "@commonpub/docs": "0.6.2",
57
- "@commonpub/explainer": "0.7.6",
58
- "@commonpub/schema": "0.9.5",
59
- "@commonpub/protocol": "0.9.7",
56
+ "@commonpub/config": "0.9.0",
60
57
  "@commonpub/learning": "0.5.0",
58
+ "@commonpub/server": "2.27.7",
59
+ "@commonpub/explainer": "0.7.6",
61
60
  "@commonpub/ui": "0.8.5",
62
- "@commonpub/server": "2.27.7"
61
+ "@commonpub/protocol": "0.9.7",
62
+ "@commonpub/schema": "0.9.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -41,6 +41,7 @@ const metadata = ref<Record<string, unknown>>({
41
41
  tags: [],
42
42
  visibility: 'public',
43
43
  coverImageUrl: '',
44
+ bannerUrl: '',
44
45
  ...(hubFromQuery ? { hubSlug: hubFromQuery } : {}),
45
46
  });
46
47
  const isDirty = ref(false);
@@ -147,6 +148,7 @@ if (!isNew.value) {
147
148
  tags: d.tags ? (d.tags as { name: string }[]).map((t) => t.name) : [],
148
149
  visibility: (d.visibility as string) || 'public',
149
150
  coverImageUrl: (d.coverImageUrl as string) || '',
151
+ bannerUrl: (d.bannerUrl as string) || '',
150
152
  seoDescription: (d.seoDescription as string) || '',
151
153
  difficulty: (d.difficulty as string) || '',
152
154
  buildTime: (d.buildTime as string) || '',