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"
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
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
130149import { detectDocChange } from ' Modules/@apostrophecms/schema/lib/detectChange' ;
131150import { klona } from ' klona' ;
132151import dayjs from ' dayjs' ;
133- import { createId , isEqual } from ' apostrophe/lib/beneath.js' ;
152+ import { createId } from ' apostrophe/lib/beneath.js' ;
134153import advancedFormat from ' dayjs/plugin/advancedFormat' ;
135154
136155dayjs .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