Skip to content

Commit 97c8a28

Browse files
authored
One-click "replace" experience for media manager (#5655)
* One-click "replace" experience for media manager * play nicely with Miro's fix
1 parent 2a6b396 commit 97c8a28

2 files changed

Lines changed: 160 additions & 43 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"apostrophe": minor
3+
---
4+
5+
Replacing an image in the media manager is simpler: the "Replace" button opens the file picker directly, and a file can be dropped on the image preview to replace it. The full attachment field with its file list is no longer shown.

‎packages/apostrophe/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue‎

Lines changed: 155 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
11
<template>
2-
<div
3-
class="apos-media-editor"
4-
:class="{
5-
'apos-is-replacing': showReplace
6-
}"
7-
>
2+
<div class="apos-media-editor">
83
<div
94
v-if="activeMedia"
105
class="apos-media-editor__inner"
116
>
12-
<div class="apos-media-editor__thumb-wrapper">
7+
<div
8+
class="apos-media-editor__thumb-wrapper"
9+
:class="{
10+
'apos-is-dragging': dragging,
11+
'apos-is-uploading': uploading
12+
}"
13+
@dragover="dragOver"
14+
@dragleave="dragging = false"
15+
@drop.prevent="dropFile"
16+
>
1317
<img
1418
v-if="activeMedia.attachment && activeMedia.attachment._urls"
1519
class="apos-media-editor__thumb"
1620
:src="activeMedia.attachment._urls[restoreOnly ? 'one-sixth' : 'one-third']"
1721
:alt="activeMedia.description || ''"
1822
>
23+
<div
24+
v-if="dragging || uploading"
25+
class="apos-media-editor__thumb-overlay"
26+
>
27+
<AposSpinner v-if="uploading" />
28+
<cloud-upload-icon
29+
v-else
30+
:size="38"
31+
/>
32+
</div>
1933
</div>
34+
<input
35+
ref="fileInput"
36+
type="file"
37+
class="apos-sr-only"
38+
:accept="attachmentField?.accept"
39+
tabindex="-1"
40+
@change="chooseFile"
41+
>
2042
<ul class="apos-media-editor__details">
2143
<li
2244
v-if="createdDate"
@@ -43,15 +65,12 @@
4365
</li>
4466
</ul>
4567
<ul class="apos-media-editor__links">
46-
<li
47-
class="apos-media-editor__link"
48-
aria-hidden="true"
49-
>
68+
<li class="apos-media-editor__link">
5069
<AposButton
5170
type="quiet"
5271
label="apostrophe:replace"
53-
:disabled="isArchived"
54-
@click="showReplace = true"
72+
:disabled="!canReplace"
73+
@click="$refs.fileInput.click()"
5574
/>
5675
</li>
5776
<li
@@ -82,7 +101,7 @@
82101
v-if="docFields.data.title !== undefined"
83102
ref="schema"
84103
v-model="docFields"
85-
:schema="schema"
104+
:schema="formSchema"
86105
:modifiers="['small', 'inverted']"
87106
:trigger-validation="triggerValidation"
88107
:doc-id="docFields.data._id"
@@ -130,7 +149,7 @@ import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixi
130149
import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange';
131150
import { klona } from 'klona';
132151
import dayjs from 'dayjs';
133-
import { createId, isEqual } from 'apostrophe/lib/beneath.js';
152+
import { createId } from 'apostrophe/lib/beneath.js';
134153
import advancedFormat from 'dayjs/plugin/advancedFormat';
135154
136155
dayjs.extend(advancedFormat);
@@ -164,10 +183,22 @@ export default {
164183
original: klona(this.media),
165184
lipKey: '',
166185
triggerValidation: false,
167-
showReplace: false
186+
dragging: false,
187+
uploading: false
168188
};
169189
},
170190
computed: {
191+
// The attachment is replaced via the "Replace" button or by dropping
192+
// a file on the preview, not via a field in the form
193+
formSchema() {
194+
return this.schema.filter(field => field.name !== 'attachment');
195+
},
196+
attachmentField() {
197+
return this.schema.find(field => field.name === 'attachment');
198+
},
199+
canReplace() {
200+
return !this.isArchived && !this.uploading && !this.attachmentField?.readOnly;
201+
},
171202
moduleOptions() {
172203
return window.apos.modules[this.activeMedia.type] || {};
173204
},
@@ -259,9 +290,14 @@ export default {
259290
// race condition when toggling selection off
260291
return this.media?.archived;
261292
},
262-
// The `isModified` prop also covers the manager's selection
293+
// The `isModified` prop also covers the manager's selection.
294+
// The attachment is not part of the form, so it is tracked
295+
// separately in activeMedia
263296
isDocModified() {
264-
return detectDocChange(this.schema, this.original, this.docFields.data);
297+
return detectDocChange(this.schema, this.original, {
298+
...this.docFields.data,
299+
attachment: this.activeMedia.attachment
300+
});
265301
},
266302
saveDisabled() {
267303
if (this.docFields.hasErrors) {
@@ -281,19 +317,9 @@ export default {
281317
if (!(Object.keys(oldData).length > 0 && Object.keys(newData).length > 0)) {
282318
this.$emit('modified', false);
283319
} else {
284-
this.$emit('modified', detectDocChange(this.schema, this.original, newData));
320+
this.emitModified();
285321
}
286322
});
287-
288-
if ((this.activeMedia.attachment && !newData.attachment)) {
289-
this.updateActiveAttachment({});
290-
} else if (
291-
(newData.attachment && !this.activeMedia.attachment) ||
292-
(this.activeMedia.attachment && !newData.attachment) ||
293-
!isEqual(newData.attachment, this.activeMedia.attachment)
294-
) {
295-
this.updateActiveAttachment(newData.attachment);
296-
}
297323
}
298324
},
299325
async media(newVal) {
@@ -308,9 +334,11 @@ export default {
308334
moreMenuHandler(item) {
309335
this[item.action]();
310336
},
337+
emitModified() {
338+
this.$emit('modified', this.isDocModified);
339+
},
311340
async updateActiveDoc(newMedia) {
312341
newMedia = newMedia || {};
313-
this.showReplace = false;
314342
this.activeMedia = klona(newMedia);
315343
this.restoreOnly = !!this.activeMedia.archived;
316344
this.original = klona(newMedia);
@@ -418,23 +446,95 @@ export default {
418446
fallback: `${errorMessage} ${this.moduleLabels.singular}`
419447
});
420448
}
421-
} finally {
422-
this.showReplace = false;
423449
}
424450
},
425451
generateLipKey() {
426452
this.lipKey = createId();
427453
},
428454
cancel() {
429-
this.showReplace = false;
430455
this.$emit('back');
431456
},
432457
lockNotAvailable() {
433458
this.$emit('modified', false);
434459
this.cancel();
435460
},
436-
updateActiveAttachment(attachment) {
437-
this.activeMedia.attachment = attachment;
461+
dragOver(event) {
462+
event.preventDefault();
463+
if (this.canReplace) {
464+
this.dragging = true;
465+
}
466+
},
467+
dropFile(event) {
468+
this.dragging = false;
469+
if (!this.canReplace) {
470+
return;
471+
}
472+
const [ file ] = event.dataTransfer.files || [];
473+
if (file) {
474+
return this.replaceFile(file);
475+
}
476+
},
477+
chooseFile(event) {
478+
const [ file ] = event.target.files || [];
479+
// Allow the same file to be chosen again later
480+
event.target.value = null;
481+
if (file) {
482+
return this.replaceFile(file);
483+
}
484+
},
485+
async replaceFile(file) {
486+
const extension = `.${file.name.split('.').pop()}`.toLowerCase();
487+
const accept = this.attachmentField?.accept;
488+
if (accept && !accept.split(',').includes(extension)) {
489+
await apos.notify('apostrophe:fileTypeNotAccepted', {
490+
type: 'warning',
491+
icon: 'alert-circle-icon',
492+
dismiss: true,
493+
interpolate: {
494+
extension,
495+
extensions: accept
496+
}
497+
});
498+
return;
499+
}
500+
this.uploading = true;
501+
try {
502+
await apos.notify('apostrophe:uploading', {
503+
dismiss: true,
504+
icon: 'cloud-upload-icon',
505+
interpolate: {
506+
name: file.name
507+
}
508+
});
509+
const formData = new window.FormData();
510+
formData.append('file', file);
511+
const attachment = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', {
512+
body: formData
513+
});
514+
await apos.notify('apostrophe:uploaded', {
515+
type: 'success',
516+
dismiss: true,
517+
icon: 'check-all-icon',
518+
interpolate: {
519+
name: file.name,
520+
count: 1
521+
}
522+
});
523+
this.activeMedia.attachment = attachment;
524+
this.emitModified();
525+
} catch (error) {
526+
// eslint-disable-next-line no-console
527+
console.error('Error uploading file.', error);
528+
const msg = error.body?.message || this.$t('apostrophe:uploadError');
529+
await apos.notify(msg, {
530+
type: 'danger',
531+
icon: 'alert-circle-icon',
532+
dismiss: true,
533+
localize: false
534+
});
535+
} finally {
536+
this.uploading = false;
537+
}
438538
},
439539
viewMedia () {
440540
window.open(this.activeMedia.attachment._urls.original, '_blank');
@@ -493,17 +593,37 @@ export default {
493593
}
494594
495595
.apos-media-editor__thumb-wrapper {
596+
position: relative;
496597
display: flex;
497598
align-items: center;
498599
justify-content: center;
499600
height: 180px;
500601
margin-bottom: 20px;
501602
border: 1px solid var(--a-base-7);
603+
transition: border-color 200ms ease;
604+
605+
&.apos-is-dragging {
606+
border: 2px dashed var(--a-primary);
607+
}
502608
}
503609
504610
.apos-media-editor__thumb {
505611
max-width: 100%;
506612
max-height: 100%;
613+
// Avoid spurious dragleave events from the child
614+
pointer-events: none;
615+
}
616+
617+
.apos-media-editor__thumb-overlay {
618+
position: absolute;
619+
inset: 0;
620+
display: flex;
621+
align-items: center;
622+
justify-content: center;
623+
color: var(--a-primary);
624+
background-color: var(--a-base-10);
625+
opacity: 0.85;
626+
pointer-events: none;
507627
}
508628
509629
.apos-media-editor :deep(.apos-field) {
@@ -548,14 +668,6 @@ export default {
548668
}
549669
}
550670
551-
:deep([data-apos-field='attachment']) {
552-
.apos-media-editor:not(.apos-is-replacing) & {
553-
position: absolute;
554-
left: -999rem;
555-
opacity: 0;
556-
}
557-
}
558-
559671
.apos-media-editor__controls {
560672
margin-bottom: 20px;
561673
}

0 commit comments

Comments
 (0)