@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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/
|
|
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/
|
|
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",
|