@commonpub/layer 0.79.0 → 0.81.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.
@@ -0,0 +1,347 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Reusable image cropper modal. Shows a fixed aspect-ratio crop frame; the user
4
+ * drags to reposition and zooms (slider / +/- / scroll / pinch) the image behind
5
+ * it, so the crop is WYSIWYG for the target element (avatar 1:1, banner 4:1,
6
+ * cover 16:9). Emits a cropped Blob. Built on vue-advanced-cropper for correct
7
+ * EXIF/touch/retina handling; styled entirely to the design system.
8
+ */
9
+ import { Cropper } from 'vue-advanced-cropper';
10
+ import 'vue-advanced-cropper/dist/style.css';
11
+
12
+ const props = withDefaults(defineProps<{
13
+ /** Source image as a data/object URL. */
14
+ src: string;
15
+ /** Crop aspect ratio (width / height), e.g. 1, 4, 16/9. */
16
+ aspectRatio: number;
17
+ /** Show a circular mask hint over the (still square) crop — for avatars.
18
+ * Avatars also export as PNG to preserve transparency (e.g. logos). */
19
+ round?: boolean;
20
+ /** Max output width in px; height derives from the aspect ratio. */
21
+ outputWidth?: number;
22
+ title?: string;
23
+ }>(), {
24
+ round: false,
25
+ outputWidth: 1600,
26
+ title: 'Crop image',
27
+ });
28
+
29
+ const emit = defineEmits<{ crop: [blob: Blob]; cancel: [] }>();
30
+
31
+ // vue-advanced-cropper's instance methods (getResult/zoom) aren't in its exported
32
+ // types; reference them through a loose handle.
33
+ interface CropperHandle {
34
+ getResult: () => { coordinates?: { width: number; height: number }; canvas?: HTMLCanvasElement };
35
+ zoom: (factor: number) => void;
36
+ }
37
+ const cropperRef = ref<CropperHandle | null>(null);
38
+
39
+ // Modal a11y: focus trap, scroll lock, Esc, focus restore (shared pattern).
40
+ const dialogRef = ref<HTMLElement | null>(null);
41
+ const visible = ref(false);
42
+ onMounted(() => { visible.value = true; });
43
+ useFocusTrap(dialogRef, () => visible.value, () => emit('cancel'));
44
+
45
+ const ZOOM_MAX = 5;
46
+ const zoom = ref(1);
47
+ /** Crop width (in image px) at min zoom = "fit". image-restriction keeps the
48
+ * image covering the stencil, so this is the largest crop width ever seen. */
49
+ let fitCropWidth = 0;
50
+ const saving = ref(false);
51
+
52
+ // Reset zoom bookkeeping when the source image changes (defensive — today the
53
+ // parent remounts the modal per file, but don't rely on that).
54
+ watch(() => props.src, () => { fitCropWidth = 0; zoom.value = 1; });
55
+
56
+ const stencilProps = computed(() => ({
57
+ aspectRatio: props.aspectRatio,
58
+ movable: false,
59
+ resizable: false,
60
+ // The frame is fixed; the user manipulates the image, not the stencil. Don't
61
+ // render resize handles/lines (they'd be dead, non-draggable decorations).
62
+ handlers: {},
63
+ lines: {},
64
+ }));
65
+
66
+ function stencilSize({ boundaries }: { boundaries: { width: number; height: number } }): { width: number; height: number } {
67
+ let w = boundaries.width;
68
+ let h = w / props.aspectRatio;
69
+ if (h > boundaries.height) {
70
+ h = boundaries.height;
71
+ w = h * props.aspectRatio;
72
+ }
73
+ return { width: Math.round(w * 0.92), height: Math.round(h * 0.92) };
74
+ }
75
+
76
+ const exportMime = computed(() => (props.round ? 'image/png' : 'image/jpeg'));
77
+ const canvasOpts = computed(() => ({
78
+ maxWidth: props.outputWidth,
79
+ maxHeight: Math.round(props.outputWidth / props.aspectRatio),
80
+ // Only flatten onto white for JPEG (no alpha). PNG keeps transparency.
81
+ ...(props.round ? {} : { fillColor: '#ffffff' }),
82
+ }));
83
+
84
+ function onChange(result: { coordinates?: { width: number } }): void {
85
+ const w = result?.coordinates?.width;
86
+ if (!w) return;
87
+ if (w > fitCropWidth) fitCropWidth = w;
88
+ if (fitCropWidth > 0) zoom.value = Math.min(ZOOM_MAX, Math.max(1, fitCropWidth / w));
89
+ }
90
+
91
+ function applyZoom(target: number): void {
92
+ const c = cropperRef.value;
93
+ if (!c || !fitCropWidth) return;
94
+ const curW = c.getResult().coordinates?.width;
95
+ if (!curW) return;
96
+ const t = Math.min(ZOOM_MAX, Math.max(1, target));
97
+ const factor = curW / (fitCropWidth / t);
98
+ if (Number.isFinite(factor) && factor > 0) c.zoom(factor);
99
+ }
100
+
101
+ function onSlider(e: Event): void { applyZoom(Number((e.target as HTMLInputElement).value)); }
102
+ function zoomIn(): void { applyZoom(zoom.value * 1.25); }
103
+ function zoomOut(): void { applyZoom(zoom.value / 1.25); }
104
+
105
+ function apply(): void {
106
+ const c = cropperRef.value;
107
+ if (!c) return;
108
+ const { canvas } = c.getResult();
109
+ if (!canvas) { emit('cancel'); return; }
110
+ saving.value = true;
111
+ canvas.toBlob((blob) => {
112
+ saving.value = false;
113
+ if (blob) emit('crop', blob); else emit('cancel');
114
+ }, exportMime.value, 0.92);
115
+ }
116
+ </script>
117
+
118
+ <template>
119
+ <Teleport to="body">
120
+ <div class="cpub-crop-overlay" @click.self="emit('cancel')">
121
+ <div ref="dialogRef" class="cpub-crop-modal" role="dialog" aria-modal="true" :aria-label="title">
122
+ <header class="cpub-crop-head">
123
+ <span class="cpub-crop-title">{{ title }}</span>
124
+ <button type="button" class="cpub-crop-x" aria-label="Cancel" @click="emit('cancel')">
125
+ <i class="fa-solid fa-xmark"></i>
126
+ </button>
127
+ </header>
128
+
129
+ <div class="cpub-crop-stage" :class="{ 'cpub-crop-stage-round': round }">
130
+ <Cropper
131
+ ref="cropperRef"
132
+ class="cpub-cropper"
133
+ :src="src"
134
+ :stencil-props="stencilProps"
135
+ :stencil-size="stencilSize"
136
+ image-restriction="stencil"
137
+ :resize-image="{ touch: true, wheel: { ratio: 0.1 }, adjustStencil: false }"
138
+ :move-image="{ touch: true, mouse: true }"
139
+ :canvas="canvasOpts"
140
+ :transitions="true"
141
+ @change="onChange"
142
+ />
143
+ <div v-if="round" class="cpub-crop-round-mask" aria-hidden="true"></div>
144
+ </div>
145
+
146
+ <div class="cpub-crop-controls">
147
+ <button type="button" class="cpub-crop-zbtn" aria-label="Zoom out" @click="zoomOut">
148
+ <i class="fa-solid fa-minus"></i>
149
+ </button>
150
+ <input
151
+ class="cpub-crop-slider"
152
+ type="range"
153
+ :min="1"
154
+ :max="ZOOM_MAX"
155
+ step="0.01"
156
+ :value="zoom"
157
+ aria-label="Zoom"
158
+ @input="onSlider"
159
+ />
160
+ <button type="button" class="cpub-crop-zbtn" aria-label="Zoom in" @click="zoomIn">
161
+ <i class="fa-solid fa-plus"></i>
162
+ </button>
163
+ </div>
164
+
165
+ <p class="cpub-crop-hint">Drag to reposition. Scroll, pinch, or use the slider to zoom.</p>
166
+
167
+ <footer class="cpub-crop-foot">
168
+ <button type="button" class="cpub-btn" @click="emit('cancel')">Cancel</button>
169
+ <button type="button" class="cpub-btn cpub-btn-primary" :disabled="saving" @click="apply">
170
+ {{ saving ? 'Saving...' : 'Apply' }}
171
+ </button>
172
+ </footer>
173
+ </div>
174
+ </div>
175
+ </Teleport>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .cpub-crop-overlay {
180
+ position: fixed;
181
+ inset: 0;
182
+ z-index: var(--z-modal, 9999);
183
+ background: var(--color-surface-scrim, rgba(0, 0, 0, 0.6));
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ padding: 20px;
188
+ }
189
+
190
+ .cpub-crop-modal {
191
+ width: 100%;
192
+ max-width: 560px;
193
+ background: var(--surface);
194
+ border: var(--border-width-default, 2px) solid var(--border);
195
+ box-shadow: var(--shadow-lg, 6px 6px 0 var(--border));
196
+ display: flex;
197
+ flex-direction: column;
198
+ }
199
+
200
+ .cpub-crop-head {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: space-between;
204
+ padding: 12px 16px;
205
+ border-bottom: var(--border-width-default, 2px) solid var(--border);
206
+ }
207
+
208
+ .cpub-crop-title {
209
+ font-family: var(--font-mono);
210
+ font-size: 0.75rem;
211
+ font-weight: 600;
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.06em;
214
+ color: var(--text);
215
+ }
216
+
217
+ .cpub-crop-x {
218
+ background: none;
219
+ border: none;
220
+ color: var(--text-dim);
221
+ cursor: pointer;
222
+ font-size: 1rem;
223
+ padding: 4px;
224
+ line-height: 1;
225
+ }
226
+ .cpub-crop-x:hover { color: var(--text); }
227
+ .cpub-crop-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
228
+
229
+ .cpub-crop-stage {
230
+ position: relative;
231
+ background: var(--surface2, var(--color-surface));
232
+ height: 340px;
233
+ overflow: hidden;
234
+ }
235
+
236
+ .cpub-cropper {
237
+ height: 100%;
238
+ width: 100%;
239
+ background: transparent;
240
+ }
241
+
242
+ /* Circular hint for avatars (the stored crop stays square). */
243
+ .cpub-crop-round-mask {
244
+ position: absolute;
245
+ inset: 0;
246
+ pointer-events: none;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ }
251
+ .cpub-crop-round-mask::after {
252
+ content: '';
253
+ height: 92%;
254
+ aspect-ratio: 1;
255
+ border-radius: 50%;
256
+ border: var(--border-width-default, 2px) dashed var(--accent);
257
+ }
258
+
259
+ /* Crop scrim: a theme-INDEPENDENT dark overlay so the crop bounds read clearly
260
+ on every theme. (`--color-surface-scrim` is near-white in light themes, which
261
+ washed the bounds out — a cropper surround must always darken.) The area
262
+ outside the crop frame is dimmed; the crop window stays bright. */
263
+ .cpub-cropper :deep(.vue-advanced-cropper__background) {
264
+ background: #1a1a1a;
265
+ }
266
+ .cpub-cropper :deep(.vue-advanced-cropper__foreground) {
267
+ background: rgba(0, 0, 0, 0.6);
268
+ }
269
+ /* Belt-and-suspenders with handlers:{}/lines:{}: never show the resize chrome. */
270
+ .cpub-cropper :deep(.vue-line-wrapper),
271
+ .cpub-cropper :deep(.vue-handler-wrapper) {
272
+ display: none;
273
+ }
274
+ /* One clean accent frame around the crop window (outline = no layout shift,
275
+ no per-side border doubling). Verified via headless render: the crop window is
276
+ `.vue-rectangle-stencil`. */
277
+ .cpub-cropper :deep(.vue-rectangle-stencil) {
278
+ outline: var(--border-width-default, 2px) solid var(--accent);
279
+ outline-offset: calc(-1 * var(--border-width-default, 2px));
280
+ }
281
+
282
+ .cpub-crop-controls {
283
+ display: flex;
284
+ align-items: center;
285
+ gap: 12px;
286
+ padding: 14px 16px 4px;
287
+ }
288
+
289
+ .cpub-crop-zbtn {
290
+ flex: 0 0 auto;
291
+ width: 30px;
292
+ height: 30px;
293
+ display: inline-flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ background: var(--surface);
297
+ border: var(--border-width-default, 2px) solid var(--border);
298
+ color: var(--text-dim);
299
+ cursor: pointer;
300
+ }
301
+ .cpub-crop-zbtn:hover { border-color: var(--accent); color: var(--accent); }
302
+ .cpub-crop-zbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
303
+
304
+ .cpub-crop-slider {
305
+ flex: 1;
306
+ appearance: none;
307
+ height: 4px;
308
+ background: var(--border);
309
+ cursor: pointer;
310
+ }
311
+ .cpub-crop-slider:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
312
+ .cpub-crop-slider::-webkit-slider-thumb {
313
+ appearance: none;
314
+ width: 16px;
315
+ height: 16px;
316
+ border-radius: 0;
317
+ background: var(--accent);
318
+ border: var(--border-width-default, 2px) solid var(--accent);
319
+ cursor: pointer;
320
+ }
321
+ .cpub-crop-slider::-moz-range-thumb {
322
+ width: 14px;
323
+ height: 14px;
324
+ background: var(--accent);
325
+ border: var(--border-width-default, 2px) solid var(--accent);
326
+ border-radius: 0;
327
+ cursor: pointer;
328
+ }
329
+
330
+ .cpub-crop-hint {
331
+ font-size: 0.6875rem;
332
+ color: var(--text-faint);
333
+ text-align: center;
334
+ padding: 4px 16px 0;
335
+ }
336
+
337
+ .cpub-crop-foot {
338
+ display: flex;
339
+ justify-content: flex-end;
340
+ gap: 10px;
341
+ padding: 16px;
342
+ }
343
+
344
+ @media (max-width: 560px) {
345
+ .cpub-crop-stage { height: 280px; }
346
+ }
347
+ </style>
@@ -5,6 +5,8 @@ const props = defineProps<{
5
5
  label?: string;
6
6
  hint?: string;
7
7
  aspectClass?: string;
8
+ /** Override the crop aspect ratio (width/height). Defaults by purpose. */
9
+ aspectRatio?: number;
8
10
  }>();
9
11
 
10
12
  const emit = defineEmits<{
@@ -15,31 +17,50 @@ const uploading = ref(false);
15
17
  const error = ref('');
16
18
  const fileInput = ref<HTMLInputElement | null>(null);
17
19
 
20
+ // The image is cropped to the target element's aspect ratio before upload, so it
21
+ // displays WYSIWYG instead of being arbitrarily cover-cropped on the page.
22
+ const cropConfig = computed(() => {
23
+ switch (props.purpose) {
24
+ case 'avatar': return { ratio: props.aspectRatio ?? 1, round: true, output: 512, title: 'Crop avatar' };
25
+ case 'banner': return { ratio: props.aspectRatio ?? 4, round: false, output: 1600, title: 'Crop banner' };
26
+ case 'cover': return { ratio: props.aspectRatio ?? 16 / 9, round: false, output: 1600, title: 'Crop cover image' };
27
+ default: return { ratio: props.aspectRatio ?? 1, round: false, output: 1200, title: 'Crop image' };
28
+ }
29
+ });
30
+
31
+ const cropSrc = ref('');
32
+
18
33
  function triggerPicker(): void {
19
34
  fileInput.value?.click();
20
35
  }
21
36
 
22
- async function handleFileChange(event: Event): Promise<void> {
37
+ function handleFileChange(event: Event): void {
23
38
  const target = event.target as HTMLInputElement;
24
39
  const file = target.files?.[0];
25
40
  if (!file) return;
26
41
 
27
42
  if (!file.type.startsWith('image/')) {
28
43
  error.value = 'Please select an image file.';
29
- return;
30
- }
31
-
32
- if (file.size > 10 * 1024 * 1024) {
44
+ } else if (file.size > 10 * 1024 * 1024) {
33
45
  error.value = 'Image must be under 10MB.';
34
- return;
46
+ } else {
47
+ error.value = '';
48
+ const reader = new FileReader();
49
+ reader.onload = () => { cropSrc.value = reader.result as string; };
50
+ reader.onerror = () => { error.value = 'Could not read the image.'; };
51
+ reader.readAsDataURL(file);
35
52
  }
53
+ target.value = '';
54
+ }
36
55
 
37
- error.value = '';
56
+ async function onCropped(blob: Blob): Promise<void> {
57
+ cropSrc.value = '';
38
58
  uploading.value = true;
39
-
59
+ error.value = '';
40
60
  try {
61
+ const ext = blob.type === 'image/png' ? 'png' : 'jpg';
41
62
  const formData = new FormData();
42
- formData.append('file', file);
63
+ formData.append('file', new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' }));
43
64
  formData.append('purpose', props.purpose);
44
65
 
45
66
  const result = await $fetch<{ url: string }>('/api/files/upload', {
@@ -52,7 +73,6 @@ async function handleFileChange(event: Event): Promise<void> {
52
73
  error.value = (err as { data?: { statusMessage?: string } })?.data?.statusMessage || 'Upload failed.';
53
74
  } finally {
54
75
  uploading.value = false;
55
- if (target) target.value = '';
56
76
  }
57
77
  }
58
78
 
@@ -94,6 +114,19 @@ function clearImage(): void {
94
114
 
95
115
  <span v-if="hint && !error" class="cpub-img-hint">{{ hint }}</span>
96
116
  <span v-if="error" class="cpub-img-error">{{ error }}</span>
117
+
118
+ <ClientOnly>
119
+ <ImageCropperModal
120
+ v-if="cropSrc"
121
+ :src="cropSrc"
122
+ :aspect-ratio="cropConfig.ratio"
123
+ :round="cropConfig.round"
124
+ :output-width="cropConfig.output"
125
+ :title="cropConfig.title"
126
+ @crop="onCropped"
127
+ @cancel="cropSrc = ''"
128
+ />
129
+ </ClientOnly>
97
130
  </div>
98
131
  </template>
99
132
 
@@ -239,6 +239,10 @@ const dateRange = computed<string>(() => {
239
239
  /* ── BANNER BAND ── full-width, clean, like other content pages' hero banner. */
240
240
  .cpub-hero-banner {
241
241
  width: 100%;
242
+ /* Match the 4:1 upload crop so the banner shows exactly as framed (WYSIWYG)
243
+ instead of being re-cropped by a fixed height. */
244
+ aspect-ratio: 4 / 1;
245
+ max-height: 360px;
242
246
  background: var(--surface2);
243
247
  border-bottom: var(--border-width-default) solid var(--border);
244
248
  overflow: hidden;
@@ -246,9 +250,8 @@ const dateRange = computed<string>(() => {
246
250
  .cpub-hero-banner img {
247
251
  display: block;
248
252
  width: 100%;
249
- max-height: 300px;
253
+ height: 100%;
250
254
  object-fit: cover;
251
- margin: 0 auto;
252
255
  }
253
256
 
254
257
  /* ── HERO BODY ── the contest's dark, patterned section. */
@@ -318,7 +321,7 @@ const dateRange = computed<string>(() => {
318
321
  @media (max-width: 768px) {
319
322
  .cpub-hero-body { padding: 32px 0; }
320
323
  .cpub-hero-inner { padding: 0 16px; }
321
- .cpub-hero-banner img { max-height: 200px; }
324
+ .cpub-hero-banner { max-height: 200px; }
322
325
  .cpub-hero-title { font-size: 24px; }
323
326
  .cpub-hero-meta { gap: 10px; }
324
327
  }
@@ -62,7 +62,9 @@ const isCompanyHub = computed(() => hubType.value === 'company');
62
62
  .cpub-hub-hero { position: relative; overflow: hidden; }
63
63
 
64
64
  .cpub-hub-banner {
65
- height: 180px;
65
+ /* Match the 4:1 upload crop so the banner shows exactly as framed (WYSIWYG). */
66
+ aspect-ratio: 4 / 1;
67
+ max-height: 320px;
66
68
  background: linear-gradient(135deg, var(--accent) 0%, var(--teal) 50%, var(--accent-border) 100%);
67
69
  position: relative;
68
70
  overflow: hidden;
@@ -177,7 +179,7 @@ const isCompanyHub = computed(() => hubType.value === 'company');
177
179
  }
178
180
 
179
181
  @media (max-width: 640px) {
180
- .cpub-hub-banner { height: 120px; }
182
+ .cpub-hub-banner { max-height: 160px; }
181
183
  .cpub-hub-meta-inner { flex-direction: column; padding: 0 16px; }
182
184
  .cpub-hub-icon { margin-top: -24px; width: 56px; height: 56px; font-size: 22px; }
183
185
  .cpub-hub-name { font-size: 1.25rem; }
@@ -86,5 +86,14 @@ export function useFocusTrap(
86
86
  onUnmounted(() => {
87
87
  document.body.style.overflow = '';
88
88
  document.removeEventListener('keydown', handleKeydown);
89
+ // Most consumers close by v-if-unmounting while still "open" (the isOpen
90
+ // getter never flips false), so the watch's else-branch never restores
91
+ // focus. Restore here too. `previousActive` is nulled by the else-branch
92
+ // when it does run, so this won't double-restore. Focusing a detached
93
+ // element is a harmless no-op.
94
+ if (previousActive) {
95
+ previousActive.focus();
96
+ previousActive = null;
97
+ }
89
98
  });
90
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,19 +51,20 @@
51
51
  "sharp": "^0.34.5",
52
52
  "shiki": "^4.0.2",
53
53
  "vue": "^3.4.0",
54
+ "vue-advanced-cropper": "^2.8.9",
54
55
  "vue-router": "^4.3.0",
55
56
  "zod": "^4.3.6",
56
57
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/docs": "0.6.3",
58
58
  "@commonpub/explainer": "0.7.15",
59
- "@commonpub/learning": "0.5.2",
59
+ "@commonpub/config": "0.22.1",
60
+ "@commonpub/docs": "0.6.3",
60
61
  "@commonpub/editor": "0.7.12",
61
- "@commonpub/schema": "0.44.0",
62
- "@commonpub/server": "2.88.0",
63
62
  "@commonpub/protocol": "0.13.0",
64
63
  "@commonpub/ui": "0.13.1",
64
+ "@commonpub/learning": "0.5.2",
65
65
  "@commonpub/theme-studio": "0.6.1",
66
- "@commonpub/config": "0.22.1"
66
+ "@commonpub/schema": "0.44.0",
67
+ "@commonpub/server": "2.88.0"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@testing-library/jest-dom": "^6.9.1",