@ditojs/admin 2.3.2 → 2.4.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/admin",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
4
4
  "type": "module",
5
5
  "description": "Dito.js Admin is a schema based admin interface for Dito.js Server, featuring auto-generated views and forms and built with Vue.js",
6
6
  "repository": "https://github.com/ditojs/dito/tree/master/packages/admin",
@@ -33,8 +33,8 @@
33
33
  "not ie_mob > 0"
34
34
  ],
35
35
  "dependencies": {
36
- "@ditojs/ui": "^2.3.2",
37
- "@ditojs/utils": "^2.3.0",
36
+ "@ditojs/ui": "^2.4.1",
37
+ "@ditojs/utils": "^2.4.1",
38
38
  "@kyvg/vue3-notification": "^2.9.0",
39
39
  "@lk77/vue3-color": "^3.0.6",
40
40
  "@tiptap/core": "^2.0.3",
@@ -74,7 +74,7 @@
74
74
  "vue-upload-component": "^3.1.8"
75
75
  },
76
76
  "devDependencies": {
77
- "@ditojs/build": "^2.3.0",
77
+ "@ditojs/build": "^2.4.1",
78
78
  "@vitejs/plugin-vue": "^4.1.0",
79
79
  "@vue/compiler-sfc": "^3.2.47",
80
80
  "pug": "^3.0.2",
@@ -83,7 +83,7 @@
83
83
  "vite": "^4.3.1"
84
84
  },
85
85
  "types": "types",
86
- "gitHead": "e8034d836e783c606746b8a51b20d8969f1472fc",
86
+ "gitHead": "aa24e36fb6be2ad0113d13a1bda6d8d8b04bd77e",
87
87
  "scripts": {
88
88
  "build": "vite build",
89
89
  "watch": "yarn build --mode 'development' --watch",
@@ -216,6 +216,7 @@ export default DitoComponent.component('DitoContainer', {
216
216
  .dito-container {
217
217
  $self: &;
218
218
 
219
+ position: relative;
219
220
  display: flex;
220
221
  flex-flow: column;
221
222
  align-items: flex-start;
@@ -248,7 +249,7 @@ export default DitoComponent.component('DitoContainer', {
248
249
  --justify: flex-end;
249
250
  }
250
251
 
251
- &:not(#{$self}--only-in-row) {
252
+ &:not(#{$self}--alone-in-row) {
252
253
  // Now only apply alignment if there are neighbouring components no the
253
254
  // same row that also align.
254
255
  // Look ahead:
@@ -5,17 +5,66 @@
5
5
  ul
6
6
  li(
7
7
  v-for="error of errors"
8
- )
9
- | {{ error }}
8
+ ) {{ error }}
10
9
  </template>
11
10
 
12
11
  <script>
12
+ import tippy from 'tippy.js'
13
13
  import DitoComponent from '../DitoComponent.js'
14
+ import { markRaw } from 'vue'
14
15
 
15
16
  // @vue/component
16
17
  export default DitoComponent.component('DitoErrors', {
17
18
  props: {
18
19
  errors: { type: Array, default: null }
20
+ },
21
+
22
+ data() {
23
+ return {
24
+ tip: null
25
+ }
26
+ },
27
+
28
+ watch: {
29
+ errors() {
30
+ this.$nextTick(this.updateErrors)
31
+ }
32
+ },
33
+
34
+ unmounted() {
35
+ this.tip?.destroy()
36
+ },
37
+
38
+ methods: {
39
+ updateErrors() {
40
+ let { tip } = this
41
+ tip?.hide()
42
+ if (this.errors) {
43
+ tip = this.tip ??= markRaw(tippy(this.$el.closest('.dito-container')))
44
+ tip.setProps({
45
+ content: this.errors.join('\n'),
46
+ theme: 'error',
47
+ trigger: 'manual',
48
+ appendTo: 'parent',
49
+ placement: 'bottom-start',
50
+ animation: 'shift-away-subtle',
51
+ popperOptions: {
52
+ modifiers: [
53
+ {
54
+ name: 'flip',
55
+ enabled: false
56
+ }
57
+ ]
58
+ },
59
+ interactive: true,
60
+ hideOnClick: false,
61
+ offset: [3, 3], // 1/2 form-spacing
62
+ zIndex: 1
63
+ })
64
+ tip.popper.addEventListener('mousedown', () => tip.hide())
65
+ tip.show()
66
+ }
67
+ }
19
68
  }
20
69
  })
21
70
  </script>
@@ -25,10 +74,9 @@ export default DitoComponent.component('DitoErrors', {
25
74
 
26
75
  .dito-errors {
27
76
  position: absolute;
28
- z-index: 1;
77
+ opacity: 0;
29
78
 
30
79
  ul {
31
- margin-top: 1px;
32
80
  color: $color-error;
33
81
  }
34
82
  }
@@ -77,6 +77,7 @@ export default DitoComponent.component('DitoHeader', {
77
77
  @import '../styles/_imports';
78
78
 
79
79
  .dito-header {
80
+ position: relative;
80
81
  background: $color-black;
81
82
  font-size: $header-font-size;
82
83
  line-height: $header-line-height;
@@ -136,7 +137,7 @@ export default DitoComponent.component('DitoHeader', {
136
137
  }
137
138
 
138
139
  .dito-spinner {
139
- margin-top: $menu-padding-ver;
140
+ margin-top: $header-padding-ver;
140
141
  }
141
142
 
142
143
  .dito-dirty {
@@ -53,23 +53,28 @@ export default DitoComponent.component('DitoMenu', {
53
53
  }
54
54
  },
55
55
 
56
- getItemHref(item) {
56
+ getItemPath(item) {
57
57
  return item?.path
58
58
  ? `/${item.path}`
59
59
  : item.items
60
- ? this.getItemHref(Object.values(item.items)[0])
60
+ ? this.getItemPath(Object.values(item.items)[0])
61
61
  : null
62
62
  },
63
63
 
64
+ getItemHref(item) {
65
+ const path = this.getItemPath(item)
66
+ return path ? this.$router.resolve(path).href : null
67
+ },
68
+
64
69
  isActiveItem(item) {
65
70
  return (
66
- this.$route.path.startsWith(this.getItemHref(item)) ||
71
+ this.$route.path.startsWith(this.getItemPath(item)) ||
67
72
  item.items && Object.values(item.items).some(this.isActiveItem)
68
73
  )
69
74
  },
70
75
 
71
76
  onClickItem(item) {
72
- const path = this.getItemHref(item)
77
+ const path = this.getItemPath(item)
73
78
  if (path) {
74
79
  this.$router.push({ path, force: true })
75
80
  }
@@ -142,7 +142,7 @@ export default DitoComponent.component('DitoPane', {
142
142
 
143
143
  focus() {
144
144
  if (this.tab) {
145
- this.$router.push({ hash: `#${this.tab}` })
145
+ return this.$router.push({ hash: `#${this.tab}` })
146
146
  }
147
147
  },
148
148
 
@@ -5,6 +5,10 @@
5
5
  position="top right"
6
6
  classes="dito-notification"
7
7
  )
8
+ Transition(name="dito-drag")
9
+ .dito-drag-overlay(
10
+ v-if="isDraggingFiles"
11
+ )
8
12
  TransitionGroup(name="dito-dialog")
9
13
  DitoDialog(
10
14
  v-for="(dialog, key) in dialogs"
@@ -69,7 +73,8 @@ export default DitoComponent.component('DitoRoot', {
69
73
  removeRoutes: null,
70
74
  dialogs: {},
71
75
  allowLogin: false,
72
- loadingCount: 0
76
+ loadingCount: 0,
77
+ isDraggingFiles: false
73
78
  }
74
79
  },
75
80
 
@@ -91,6 +96,8 @@ export default DitoComponent.component('DitoRoot', {
91
96
  },
92
97
 
93
98
  async mounted() {
99
+ this.setupDragAndDrop()
100
+
94
101
  tippyDelegate(this.$el, {
95
102
  target: '.dito-info',
96
103
  onCreate(instance) {
@@ -103,6 +110,7 @@ export default DitoComponent.component('DitoRoot', {
103
110
  })
104
111
  }
105
112
  })
113
+
106
114
  // Clear the label marked as active on all mouse and keyboard events, except
107
115
  // the ones that DitoLabel itself intercepts.
108
116
  this.domOn(document, {
@@ -118,6 +126,7 @@ export default DitoComponent.component('DitoRoot', {
118
126
  }
119
127
  }
120
128
  })
129
+
121
130
  try {
122
131
  this.allowLogin = false
123
132
  if (await this.fetchUser()) {
@@ -132,6 +141,80 @@ export default DitoComponent.component('DitoRoot', {
132
141
  },
133
142
 
134
143
  methods: {
144
+ setupDragAndDrop() {
145
+ // This code only happens the visual effects around dragging and dropping
146
+ // files into a `DitoTypeUpload` component. The actual uploading is
147
+ // handled by the `DitoTypeUpload` component itself.
148
+
149
+ let dragCount = 0
150
+ let uploads = []
151
+
152
+ const toggleDropTargetClass = enabled => {
153
+ for (const upload of uploads) {
154
+ upload
155
+ .closest('.dito-container')
156
+ .classList.toggle('dito-drop-target', enabled)
157
+ }
158
+ if (!enabled) {
159
+ uploads = []
160
+ }
161
+ }
162
+
163
+ const setDraggingFiles = enabled => {
164
+ this.isDraggingFiles = enabled
165
+ if (enabled) {
166
+ toggleDropTargetClass(true)
167
+ } else {
168
+ setTimeout(() => toggleDropTargetClass(false), 150)
169
+ }
170
+ }
171
+
172
+ this.domOn(document, {
173
+ dragenter: event => {
174
+ if (!dragCount && event.dataTransfer) {
175
+ uploads = document.querySelectorAll('.dito-upload')
176
+ const hasUploads = uploads.length > 0
177
+ event.dataTransfer.effectAllowed = hasUploads ? 'copy' : 'none'
178
+ if (hasUploads) {
179
+ setDraggingFiles(true)
180
+ } else {
181
+ event.preventDefault()
182
+ event.stopPropagation()
183
+ return
184
+ }
185
+ }
186
+ dragCount++
187
+ },
188
+
189
+ dragleave: event => {
190
+ dragCount--
191
+ if (!dragCount && event.dataTransfer) {
192
+ setDraggingFiles(false)
193
+ }
194
+ },
195
+
196
+ dragover: event => {
197
+ if (event.dataTransfer) {
198
+ const canDrop = event.target.closest(
199
+ '.dito-container:has(.dito-upload)'
200
+ )
201
+ event.dataTransfer.dropEffect = canDrop ? 'copy' : 'none'
202
+ if (!canDrop) {
203
+ event.preventDefault()
204
+ event.stopPropagation()
205
+ }
206
+ }
207
+ },
208
+
209
+ drop: event => {
210
+ dragCount = 0
211
+ if (event.dataTransfer) {
212
+ setDraggingFiles(false)
213
+ }
214
+ }
215
+ })
216
+ },
217
+
135
218
  notify({ type = 'info', title, text } = {}) {
136
219
  title ||= (
137
220
  {
@@ -389,4 +472,46 @@ function addRoutes(router, routes) {
389
472
  background: $content-color-background;
390
473
  }
391
474
  }
475
+
476
+ .dito-drag-overlay {
477
+ position: fixed;
478
+ top: 0;
479
+ left: 0;
480
+ z-index: $drag-overlay-z-index;
481
+ width: 100%;
482
+ height: 100%;
483
+ background: rgba(0, 0, 0, 0.25);
484
+ pointer-events: none;
485
+ backdrop-filter: blur(8px);
486
+ }
487
+
488
+ .dito-drop-target {
489
+ --shadow-alpha: 0.25;
490
+
491
+ background: $content-color-background;
492
+ border-radius: $border-radius;
493
+ z-index: $drag-overlay-z-index + 1;
494
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, var(--shadow-alpha)));
495
+ }
496
+
497
+ .dito-drag-enter-active,
498
+ .dito-drag-leave-active {
499
+ $duration: 0.15s;
500
+
501
+ transition: opacity $duration, backdrop-filter $duration;
502
+
503
+ ~ * .dito-drop-target {
504
+ transition: filter $duration;
505
+ }
506
+ }
507
+
508
+ .dito-drag-enter-from,
509
+ .dito-drag-leave-to {
510
+ opacity: 0;
511
+ backdrop-filter: blur(0);
512
+
513
+ ~ * .dito-drop-target {
514
+ --shadow-alpha: 0;
515
+ }
516
+ }
392
517
  </style>
@@ -3,7 +3,10 @@ slot(name="before")
3
3
  .dito-schema(
4
4
  v-bind="$attrs"
5
5
  )
6
- .dito-schema-content(:class="{ 'dito-scroll': scrollable }")
6
+ .dito-schema-content(
7
+ ref="content"
8
+ :class="{ 'dito-scroll': scrollable }"
9
+ )
7
10
  Teleport(
8
11
  to=".dito-header__menu"
9
12
  :disabled="!headerInMenu"
@@ -280,6 +283,10 @@ export default DitoComponent.component('DitoSchema', {
280
283
  return this.everyComponent(it => it.isValidated)
281
284
  },
282
285
 
286
+ hasErrors() {
287
+ return this.someComponent(it => it.hasErrors)
288
+ },
289
+
283
290
  hasData() {
284
291
  return !!this.data
285
292
  },
@@ -334,6 +341,9 @@ export default DitoComponent.component('DitoSchema', {
334
341
  handler(hash) {
335
342
  if (this.hasTabs) {
336
343
  this.currentTab = hash?.slice(1) || null
344
+ if (this.hasErrors) {
345
+ this.repositionErrors()
346
+ }
337
347
  }
338
348
  }
339
349
  },
@@ -453,9 +463,18 @@ export default DitoComponent.component('DitoSchema', {
453
463
  }
454
464
  },
455
465
 
466
+ repositionErrors() {
467
+ // Force repositioning of error tooltips, as otherwise they
468
+ // sometimes don't show up in the right place initially when
469
+ // changing tabs
470
+ const scrollParent = this.$refs.content.closest('.dito-scroll')
471
+ scrollParent.scrollTop++
472
+ scrollParent.scrollTop--
473
+ },
474
+
456
475
  focus() {
457
- this.parentSchemaComponent?.focus()
458
476
  this.opened = true
477
+ return this.parentSchemaComponent?.focus()
459
478
  },
460
479
 
461
480
  validateAll(match, notify = true) {
@@ -485,7 +504,7 @@ export default DitoComponent.component('DitoSchema', {
485
504
  if (!component.validate(notify)) {
486
505
  // Focus first error field
487
506
  if (notify && first) {
488
- component.focus()
507
+ component.scrollIntoView()
489
508
  }
490
509
  first = false
491
510
  isValid = false
@@ -94,6 +94,7 @@ export default DitoComponent.component('DitoTableHead', {
94
94
 
95
95
  .dito-button {
96
96
  // Convention: Nested spans handle padding, see below
97
+ display: block; // Override default inline-flex positioning.
97
98
  padding: 0;
98
99
  width: 100%;
99
100
  text-align: inherit;
@@ -0,0 +1,197 @@
1
+ <template lang="pug">
2
+ .dito-upload-file
3
+ .dito-thumbnail(
4
+ v-if="thumbnail"
5
+ :class="`dito-thumbnail--${thumbnail}`"
6
+ )
7
+ .dito-thumbnail__inner
8
+ img(
9
+ v-if="source"
10
+ :src="source"
11
+ )
12
+ .dito-thumbnail__type(
13
+ v-else
14
+ )
15
+ span {{ type }}
16
+ span {{ file.name }}
17
+ </template>
18
+
19
+ <script>
20
+ import DitoComponent from '../DitoComponent.js'
21
+
22
+ // @vue/component
23
+ export default DitoComponent.component('DitoUploadFile', {
24
+ props: {
25
+ file: { type: Object, required: true },
26
+ thumbnail: { type: String, default: null },
27
+ thumbnailUrl: { type: String, default: null }
28
+ },
29
+
30
+ data() {
31
+ return {
32
+ uploadUrl: null
33
+ }
34
+ },
35
+
36
+ computed: {
37
+ type() {
38
+ return (
39
+ TYPES[this.file.type] ||
40
+ this.file.name.split('.').pop().toUpperCase()
41
+ )
42
+ },
43
+
44
+ source() {
45
+ return this.uploadUrl || this.thumbnailUrl
46
+ }
47
+ },
48
+
49
+ watch: {
50
+ 'file.upload.file': {
51
+ immediate: true,
52
+ handler(file) {
53
+ if (file && this.thumbnail) {
54
+ const reader = new FileReader()
55
+ reader.onload = () => {
56
+ this.uploadUrl = reader.result
57
+ }
58
+ reader.readAsDataURL(file)
59
+ } else {
60
+ this.uploadUrl = null
61
+ }
62
+ }
63
+ }
64
+ }
65
+ })
66
+
67
+ const TYPES = {
68
+ 'text/plain': 'TXT',
69
+ 'text/html': 'HTML',
70
+ 'text/css': 'CSS',
71
+ 'text/javascript': 'JS',
72
+ 'image/jpeg': 'JPG',
73
+ 'image/png': 'PNG',
74
+ 'image/gif': 'GIF',
75
+ 'image/svg+xml': 'SVG',
76
+ 'movie/mp4': 'MP4',
77
+ 'audio/mpeg': 'MP3',
78
+ 'application/json': 'JSON',
79
+ 'application/xml': 'XML',
80
+ 'application/pdf': 'PDF',
81
+ 'application/zip': 'ZIP'
82
+ }
83
+ </script>
84
+
85
+ <style lang="scss">
86
+ @use 'sass:math';
87
+ @import '../styles/_imports';
88
+
89
+ .dito-upload-file {
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: flex-start;
93
+ }
94
+
95
+ .dito-thumbnail {
96
+ $self: &;
97
+
98
+ // Small size by default
99
+ --max-size: #{1 * $input-height};
100
+ --corner-size: calc(var(--max-size) / 5);
101
+ --shadow-size: 1px;
102
+ --min-size: calc(2 * var(--corner-size));
103
+ --margin: 0em;
104
+ --drop-shadow: drop-shadow(
105
+ 0 calc(var(--shadow-size) * 0.75) var(--shadow-size) #{rgba(
106
+ $color-black,
107
+ 0.4
108
+ )}
109
+ );
110
+
111
+ position: relative;
112
+ margin: var(--margin);
113
+ margin-right: 0.5em;
114
+ filter: var(--drop-shadow);
115
+
116
+ &--small {
117
+ --max-size: #{1 * $input-height};
118
+ --margin: 0em;
119
+ --shadow-size: 1px;
120
+ }
121
+
122
+ &--medium {
123
+ --max-size: #{2 * $input-height};
124
+ --margin: 0.25em;
125
+ --shadow-size: 1.5px;
126
+ }
127
+
128
+ &--large {
129
+ --max-size: #{4 * $input-height};
130
+ --margin: 0.5em;
131
+ --shadow-size: 2.5px;
132
+ }
133
+
134
+ &__inner {
135
+ background: #ffffff;
136
+ clip-path: polygon(
137
+ 0 0,
138
+ calc(100% - var(--corner-size)) 0,
139
+ 100% var(--corner-size),
140
+ 100% 100%,
141
+ 0 100%
142
+ );
143
+
144
+ &::after {
145
+ content: '';
146
+ position: absolute;
147
+ top: 0;
148
+ right: 0;
149
+ width: var(--corner-size);
150
+ height: var(--corner-size);
151
+ background: linear-gradient(45deg, #ffffff, #eeeeee 40%, #dddddd 50%);
152
+ filter: var(--drop-shadow);
153
+ }
154
+ }
155
+
156
+ &__type {
157
+ --font-size: var(--corner-size);
158
+
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ min-width: var(--min-size);
163
+ min-height: var(--max-size);
164
+ aspect-ratio: 3 / 4;
165
+
166
+ span {
167
+ --color: #{$color-grey};
168
+
169
+ font-size: min(var(--font-size), #{1.25 * $font-size});
170
+ color: var(--color);
171
+
172
+ #{$self}:not(#{$self}--small) & {
173
+ padding: 0 calc(var(--font-size) / 4);
174
+ border-radius: calc(var(--font-size) / 4);
175
+ background: var(--color);
176
+ color: #ffffff;
177
+ }
178
+
179
+ #{$self}--medium & {
180
+ --color: #{$color-light};
181
+ }
182
+
183
+ #{$self}--large & {
184
+ --color: #{$color-lighter};
185
+ }
186
+ }
187
+ }
188
+
189
+ img {
190
+ display: block;
191
+ min-width: var(--min-size);
192
+ min-height: var(--min-size);
193
+ max-width: var(--max-size);
194
+ max-height: var(--max-size);
195
+ }
196
+ }
197
+ </style>
@@ -31,5 +31,6 @@ export { default as DitoPagination } from './DitoPagination.vue'
31
31
  export { default as DitoTreeItem } from './DitoTreeItem.vue'
32
32
  export { default as DitoTableHead } from './DitoTableHead.vue'
33
33
  export { default as DitoTableCell } from './DitoTableCell.vue'
34
+ export { default as DitoUploadFile } from './DitoUploadFile.vue'
34
35
  export { default as DitoDraggable } from './DitoDraggable.vue'
35
36
  export { default as DitoVNode } from './DitoVNode.vue'
@@ -192,19 +192,36 @@ export default {
192
192
  return value
193
193
  },
194
194
 
195
- getChildStore(key) {
196
- return this.getStore(key) || this.setStore(key, reactive({}))
195
+ getChildStore(key, index) {
196
+ // When storing, temporary ids change to permanent ones and thus the key
197
+ // can change, so we need to store the index as well, to be able to find
198
+ // the store again after the item was saved.
199
+ const store = (
200
+ this.getStore(key) ||
201
+ index != null && this.getStore(index) ||
202
+ this.setStore(key, reactive({}))
203
+ )
204
+ if (index != null) {
205
+ this.setStore(index, store)
206
+ }
207
+ return store
197
208
  },
198
209
 
199
210
  getSchemaValue(
200
211
  keyOrDataPath,
201
- { type, schema = this.schema, callback = true, default: def } = {}
212
+ {
213
+ type,
214
+ default: def,
215
+ schema = this.schema,
216
+ context = this.context,
217
+ callback = true
218
+ } = {}
202
219
  ) {
203
220
  return getSchemaValue(keyOrDataPath, {
204
221
  type,
205
222
  schema,
223
+ context,
206
224
  callback,
207
- context: this.context,
208
225
  default: isFunction(def) ? () => def.call(this) : def
209
226
  })
210
227
  },
@@ -365,7 +382,9 @@ export default {
365
382
  }
366
383
  // See: https://stackoverflow.com/a/49917066/1163708
367
384
  const a = document.createElement('a')
368
- a.href = this.api.getApiUrl(options)
385
+ a.href = options.url?.startsWith('blob:')
386
+ ? options.url
387
+ : this.api.getApiUrl(options)
369
388
  a.download = options.filename ?? null
370
389
  const { body } = document
371
390
  body.appendChild(a)