@commonpub/layer 0.8.0 → 0.8.2

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.
@@ -82,25 +82,77 @@ function scrollToSection(sectionId: string): void {
82
82
  }
83
83
 
84
84
  // --- Assets ---
85
- const uploadedFiles = ref<Array<{ name: string; size: string; type: string }>>([]);
85
+ const MAX_CONTENT_UPLOAD_MB = 10;
86
+ const MAX_CONTENT_UPLOAD_BYTES = MAX_CONTENT_UPLOAD_MB * 1024 * 1024;
87
+
88
+ interface UploadedAsset {
89
+ name: string;
90
+ size: string;
91
+ type: 'image' | 'file';
92
+ url: string;
93
+ mimeType: string;
94
+ }
95
+
96
+ const uploadedFiles = ref<UploadedAsset[]>([]);
97
+ const uploadError = ref('');
98
+ const uploading = ref(false);
86
99
 
87
100
  function onAssetUpload(event: Event): void {
88
101
  const input = event.target as HTMLInputElement;
89
102
  if (!input.files?.length) return;
90
103
  const file = input.files[0];
91
104
  if (!file) return;
105
+ uploadError.value = '';
106
+
107
+ // Client-side size check
108
+ if (file.size > MAX_CONTENT_UPLOAD_BYTES) {
109
+ uploadError.value = `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max ${MAX_CONTENT_UPLOAD_MB} MB.`;
110
+ input.value = '';
111
+ return;
112
+ }
113
+
114
+ uploading.value = true;
92
115
  const formData = new FormData();
93
116
  formData.append('file', file);
94
117
  formData.append('purpose', 'content');
95
- $fetch<{ url: string; originalName: string; size: number }>('/api/files/upload', { method: 'POST', body: formData })
118
+ $fetch<{ url: string; originalName: string; sizeBytes: number; mimeType: string }>('/api/files/upload', { method: 'POST', body: formData })
96
119
  .then((res) => {
97
120
  uploadedFiles.value.unshift({
98
121
  name: res.originalName || file.name,
99
- size: `${(res.size / 1024).toFixed(0)} KB`,
100
- type: file.type.startsWith('image/') ? 'image' : 'file',
122
+ size: res.sizeBytes < 1024 * 1024
123
+ ? `${(res.sizeBytes / 1024).toFixed(0)} KB`
124
+ : `${(res.sizeBytes / 1024 / 1024).toFixed(1)} MB`,
125
+ type: (res.mimeType || file.type).startsWith('image/') ? 'image' : 'file',
126
+ url: res.url,
127
+ mimeType: res.mimeType || file.type,
101
128
  });
129
+ uploadError.value = '';
102
130
  })
103
- .catch(() => { /* silent */ });
131
+ .catch((err) => {
132
+ uploadError.value = err?.data?.statusMessage || 'Upload failed';
133
+ })
134
+ .finally(() => {
135
+ uploading.value = false;
136
+ input.value = '';
137
+ });
138
+ }
139
+
140
+ function insertAsset(asset: UploadedAsset): void {
141
+ if (asset.type === 'image') {
142
+ // Insert image block after selected block (or at end)
143
+ const idx = props.blockEditor.selectedBlockId.value
144
+ ? props.blockEditor.getBlockIndex(props.blockEditor.selectedBlockId.value) + 1
145
+ : undefined;
146
+ props.blockEditor.addBlock('image', { url: asset.url, alt: asset.name }, idx);
147
+ } else {
148
+ // Copy URL to clipboard for non-image files
149
+ navigator.clipboard.writeText(asset.url).catch(() => {});
150
+ asset.name = `${asset.name} (copied!)`;
151
+ setTimeout(() => {
152
+ const found = uploadedFiles.value.find((f) => f.url === asset.url);
153
+ if (found) found.name = found.name.replace(' (copied!)', '');
154
+ }, 1500);
155
+ }
104
156
  }
105
157
 
106
158
  // --- Cover image ---
@@ -264,25 +316,34 @@ const canvasMaxWidth = computed(() => {
264
316
 
265
317
  <!-- Assets tab -->
266
318
  <div v-else class="cpub-ae-left-body">
267
- <label class="cpub-ae-assets-drop">
268
- <i class="fa-solid fa-cloud-arrow-up"></i>
269
- <div class="cpub-ae-assets-drop-label">Drop files here</div>
270
- <div class="cpub-ae-assets-drop-sub">JPG, PNG, GIF, SVG, PDF</div>
271
- <input type="file" class="cpub-sr-only" @change="onAssetUpload">
319
+ <label class="cpub-ae-assets-drop" :class="{ 'cpub-ae-assets-uploading': uploading }">
320
+ <i :class="uploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-cloud-arrow-up'"></i>
321
+ <div class="cpub-ae-assets-drop-label">{{ uploading ? 'Uploading...' : 'Drop files here' }}</div>
322
+ <div class="cpub-ae-assets-drop-sub">JPG, PNG, GIF, SVG, PDF — max {{ MAX_CONTENT_UPLOAD_MB }} MB</div>
323
+ <input type="file" class="cpub-sr-only" :disabled="uploading" @change="onAssetUpload">
272
324
  </label>
325
+ <div v-if="uploadError" class="cpub-ae-assets-error">
326
+ <i class="fa-solid fa-triangle-exclamation"></i> {{ uploadError }}
327
+ </div>
273
328
  <div v-if="uploadedFiles.length > 0" class="cpub-ae-assets-list">
274
329
  <div class="cpub-ae-assets-heading">Recent Uploads</div>
275
330
  <div
276
331
  v-for="(file, idx) in uploadedFiles"
277
332
  :key="idx"
278
333
  class="cpub-ae-asset-item"
334
+ :title="file.type === 'image' ? 'Click to insert image' : 'Click to copy URL'"
335
+ @click="insertAsset(file)"
279
336
  >
280
- <div class="cpub-ae-asset-icon">
281
- <i :class="file.type === 'image' ? 'fa-solid fa-image' : 'fa-solid fa-file'" />
337
+ <img v-if="file.type === 'image'" :src="file.url" :alt="file.name" class="cpub-ae-asset-thumb" />
338
+ <div v-else class="cpub-ae-asset-icon">
339
+ <i class="fa-solid fa-file" />
282
340
  </div>
283
341
  <div class="cpub-ae-asset-info">
284
342
  <div class="cpub-ae-asset-name">{{ file.name }}</div>
285
- <div class="cpub-ae-asset-size">{{ file.size }}</div>
343
+ <div class="cpub-ae-asset-meta">
344
+ <span class="cpub-ae-asset-size">{{ file.size }}</span>
345
+ <span class="cpub-ae-asset-action">{{ file.type === 'image' ? 'insert' : 'copy url' }}</span>
346
+ </div>
286
347
  </div>
287
348
  </div>
288
349
  </div>
@@ -358,6 +419,21 @@ const canvasMaxWidth = computed(() => {
358
419
  <textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief description shown in feed previews..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
359
420
  <span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.description as string) || '').length }} / 300</span>
360
421
  </div>
422
+ <div class="cpub-ep-field">
423
+ <label class="cpub-ep-flabel">Category</label>
424
+ <select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
425
+ <option value="">Select category</option>
426
+ <option value="article">Article</option>
427
+ <option value="blog">Blog Post</option>
428
+ <option value="tutorial">Tutorial</option>
429
+ <option value="deep-dive">Deep Dive</option>
430
+ <option value="opinion">Opinion</option>
431
+ <option value="hardware">Hardware &amp; Makers</option>
432
+ <option value="software">Software</option>
433
+ <option value="ai-ml">AI &amp; Machine Learning</option>
434
+ <option value="homelab">Home Lab</option>
435
+ </select>
436
+ </div>
361
437
  <div class="cpub-ae-cover" :class="{ 'has-image': !!coverImageUrl }">
362
438
  <template v-if="coverImageUrl">
363
439
  <img :src="coverImageUrl" alt="Cover image" class="cpub-ae-cover-img" />
@@ -429,21 +505,6 @@ const canvasMaxWidth = computed(() => {
429
505
  <label class="cpub-ep-flabel">Visibility</label>
430
506
  <EditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
431
507
  </div>
432
- <div class="cpub-ep-field">
433
- <label class="cpub-ep-flabel">Category</label>
434
- <select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
435
- <option value="">Select category</option>
436
- <option value="article">Article</option>
437
- <option value="blog">Blog Post</option>
438
- <option value="tutorial">Tutorial</option>
439
- <option value="deep-dive">Deep Dive</option>
440
- <option value="opinion">Opinion</option>
441
- <option value="hardware">Hardware &amp; Makers</option>
442
- <option value="software">Software</option>
443
- <option value="ai-ml">AI &amp; Machine Learning</option>
444
- <option value="homelab">Home Lab</option>
445
- </select>
446
- </div>
447
508
  <div class="cpub-ep-field">
448
509
  <label class="cpub-ep-flabel">Tags</label>
449
510
  <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
@@ -505,15 +566,26 @@ const canvasMaxWidth = computed(() => {
505
566
  transition: border-color 0.15s, background 0.15s; text-align: center;
506
567
  }
507
568
  .cpub-ae-assets-drop:hover { border-color: var(--accent); background: var(--accent-bg); }
569
+ .cpub-ae-assets-uploading { border-color: var(--accent); background: var(--accent-bg); pointer-events: none; }
508
570
  .cpub-ae-assets-drop i { font-size: 20px; color: var(--text-faint); }
509
571
  .cpub-ae-assets-drop-label { font-size: 11px; font-weight: 600; color: var(--text-dim); }
510
572
  .cpub-ae-assets-drop-sub { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
573
+ .cpub-ae-assets-error {
574
+ margin: 4px 8px 8px; padding: 6px 10px; font-size: 10px; font-family: var(--font-mono);
575
+ color: var(--red); background: var(--red-bg); border: var(--border-width-default) solid var(--red);
576
+ display: flex; align-items: center; gap: 6px;
577
+ }
511
578
  .cpub-ae-assets-list { padding: 8px 12px; }
512
579
  .cpub-ae-assets-heading { font-family: var(--font-mono); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-faint); padding: 4px 0 10px; }
513
580
  .cpub-ae-asset-item {
514
581
  display: flex; align-items: center; gap: 10px; padding: 8px 10px;
515
582
  background: var(--surface); border: var(--border-width-default) solid var(--border); cursor: pointer;
516
- box-shadow: var(--shadow-sm); margin-bottom: 5px;
583
+ box-shadow: var(--shadow-sm); margin-bottom: 5px; transition: background 0.1s, border-color 0.1s;
584
+ }
585
+ .cpub-ae-asset-item:hover { background: var(--surface2); border-color: var(--accent-border); }
586
+ .cpub-ae-asset-thumb {
587
+ width: 34px; height: 34px; object-fit: cover; flex-shrink: 0;
588
+ border: var(--border-width-default) solid var(--border2);
517
589
  }
518
590
  .cpub-ae-asset-icon {
519
591
  width: 34px; height: 34px; background: var(--surface2); display: flex;
@@ -522,7 +594,12 @@ const canvasMaxWidth = computed(() => {
522
594
  .cpub-ae-asset-icon i { font-size: 11px; color: var(--text-faint); }
523
595
  .cpub-ae-asset-info { flex: 1; min-width: 0; }
524
596
  .cpub-ae-asset-name { font-size: 10px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
597
+ .cpub-ae-asset-meta { display: flex; align-items: center; gap: 6px; }
525
598
  .cpub-ae-asset-size { font-family: var(--font-mono); font-size: 8px; color: var(--text-faint); }
599
+ .cpub-ae-asset-action {
600
+ font-family: var(--font-mono); font-size: 8px; color: var(--accent);
601
+ text-transform: uppercase; letter-spacing: 0.05em;
602
+ }
526
603
 
527
604
  /* Center */
528
605
  .cpub-ae-center { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
@@ -30,11 +30,14 @@ const tocHeadings = computed(() => {
30
30
  const block = b as [string, Record<string, unknown>];
31
31
  return block[0] === 'heading';
32
32
  })
33
- .map((b: unknown, idx: number) => {
33
+ .map((b: unknown) => {
34
34
  const block = b as [string, Record<string, unknown>];
35
+ const text = (block[1].text as string) || 'Untitled';
36
+ // Must match BlockHeadingView's slug generation
37
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
35
38
  return {
36
- id: `heading-${idx}`,
37
- text: (block[1].text as string) || 'Untitled',
39
+ id: slug,
40
+ text,
38
41
  level: (block[1].level as number) || 2,
39
42
  };
40
43
  });
@@ -298,21 +301,18 @@ useJsonLd({
298
301
 
299
302
  <style scoped>
300
303
  /* ── TOC SIDEBAR ── */
301
- .cpub-article-toc-sidebar {
304
+ .cpub-article-body-layout {
302
305
  display: none;
303
306
  }
304
307
  @media (min-width: 1200px) {
305
308
  .cpub-article-body-layout {
306
- position: fixed;
307
- right: max(24px, calc((100vw - 720px) / 2 - 240px));
308
- top: 120px;
309
+ display: block;
310
+ position: absolute;
311
+ left: calc(100% + 32px);
312
+ top: 0;
309
313
  width: 200px;
310
- z-index: 10;
311
- pointer-events: none;
312
314
  }
313
315
  .cpub-article-toc-sidebar {
314
- display: block;
315
- pointer-events: auto;
316
316
  position: sticky;
317
317
  top: 80px;
318
318
  }
@@ -379,6 +379,7 @@ useJsonLd({
379
379
  max-width: 720px;
380
380
  margin: 0 auto;
381
381
  padding: 40px clamp(12px, 4vw, 24px) 80px;
382
+ position: relative;
382
383
  }
383
384
 
384
385
  /* ── TYPE BADGE ── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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/auth": "0.5.0",
54
53
  "@commonpub/editor": "0.7.9",
55
- "@commonpub/learning": "0.5.0",
56
54
  "@commonpub/config": "0.9.1",
55
+ "@commonpub/explainer": "0.7.10",
57
56
  "@commonpub/docs": "0.6.2",
58
- "@commonpub/protocol": "0.9.8",
59
- "@commonpub/schema": "0.9.6",
57
+ "@commonpub/auth": "0.5.0",
60
58
  "@commonpub/ui": "0.8.5",
59
+ "@commonpub/schema": "0.9.6",
60
+ "@commonpub/protocol": "0.9.8",
61
61
  "@commonpub/server": "2.28.0",
62
- "@commonpub/explainer": "0.7.10"
62
+ "@commonpub/learning": "0.5.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",