@dile/crud 1.14.6 → 1.14.8

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.
@@ -20,9 +20,14 @@ export class DileImageUploader extends DileI18nMixin(LitElement) {
20
20
  saveLabel: { type: String },
21
21
  allowedExtensions: { type: Array },
22
22
  responseAdapter: { type: Object },
23
+ maxFileSize: { type: Number },
24
+ minWidth: { type: Number },
25
+ maxWidth: { type: Number },
26
+ minHeight: { type: Number },
27
+ maxHeight: { type: Number },
23
28
  };
24
29
  }
25
-
30
+
26
31
  constructor() {
27
32
  super();
28
33
  this.responseAdapter = new ResponseApiAdapter();
@@ -41,11 +46,16 @@ export class DileImageUploader extends DileI18nMixin(LitElement) {
41
46
  @save-success=${this.imageSaveSuccess}
42
47
  .responseAdapter=${this.responseAdapter}
43
48
  >
44
- <dile-image-uploader-form
49
+ <dile-image-uploader-form
45
50
  id="form"
46
51
  label="${this.selectLabelComputed(this.selectImageLabel, this.translations)}"
47
52
  .allowedExtensions=${this.allowedExtensions}
48
53
  .translations=${this.translations}
54
+ .maxFileSize=${this.maxFileSize}
55
+ .minWidth=${this.minWidth}
56
+ .maxWidth=${this.maxWidth}
57
+ .minHeight=${this.minHeight}
58
+ .maxHeight=${this.maxHeight}
49
59
  ></dile-image-uploader-form>
50
60
  </dile-ajax-form>
51
61
  `;
@@ -1,6 +1,11 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileForm } from '@dile/ui/mixins/form/index.js'
3
3
  import '@dile/ui/components/drop-file/drop-file.js';
4
+ import { readImageFile } from '../../../lib/image/readImageFile.js';
5
+ import { validateImageFile } from '../../../lib/image/validateImageFile.js';
6
+ import { createThumbnail } from '../../../lib/image/createThumbnail.js';
7
+ import { formatFileSize } from '../../../lib/image/formatFileSize.js';
8
+ import { buildValidationMessages } from '../../../lib/image/buildValidationMessages.js';
4
9
 
5
10
  export class DileImageUploaderForm extends DileForm(LitElement) {
6
11
  static styles = [
@@ -11,6 +16,19 @@ export class DileImageUploaderForm extends DileForm(LitElement) {
11
16
  dile-drop-file {
12
17
  margin-bottom: 0;
13
18
  }
19
+ .image-summary {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 0.75rem;
23
+ margin: 0.5rem 0;
24
+ font-size: var(--dile-file-preview-font-size, 0.8rem);
25
+ }
26
+ .image-summary img {
27
+ max-width: 60px;
28
+ max-height: 60px;
29
+ border-radius: 4px;
30
+ display: block;
31
+ }
14
32
  `
15
33
  ];
16
34
 
@@ -19,21 +37,101 @@ export class DileImageUploaderForm extends DileForm(LitElement) {
19
37
  label: { type: String },
20
38
  allowedExtensions: { type: Array },
21
39
  translations: { type: Object },
40
+ maxFileSize: { type: Number },
41
+ minWidth: { type: Number },
42
+ maxWidth: { type: Number },
43
+ minHeight: { type: Number },
44
+ maxHeight: { type: Number },
45
+ imageInfo: { state: true },
22
46
  };
23
47
  }
24
48
 
49
+ constructor() {
50
+ super();
51
+ this.imageInfo = null;
52
+ this._inspectToken = 0;
53
+ }
54
+
25
55
  render() {
26
56
  return html`
27
- <dile-drop-file
57
+ <dile-drop-file
28
58
  name="image"
29
- label="${this.label}"
30
- id="dropfile"
59
+ label="${this.label}"
60
+ id="dropfile"
31
61
  .allowedExtensions=${this.allowedExtensions}
32
62
  dropLabel="${this.translations.file_drop}"
33
63
  buttonLabel="${this.translations.select_file}"
34
64
  selectedFileLabel="${this.translations.selected_file}"
35
65
  extensionErrorMessage="${this.translations.extension_allowed}"
66
+ @element-changed=${this.handleFileChange}
36
67
  ></dile-drop-file>
68
+ ${this.imageSummaryTemplate}
37
69
  `;
38
70
  }
71
+
72
+ get imageSummaryTemplate() {
73
+ if (!this.imageInfo) {
74
+ return '';
75
+ }
76
+ return html`
77
+ <div class="image-summary">
78
+ <img src="${this.imageInfo.thumbnail}" alt="">
79
+ <span>${this.translations.image_dimensions_summary(this.imageInfo.width, this.imageInfo.height, formatFileSize(this.imageInfo.size))}</span>
80
+ </div>
81
+ `;
82
+ }
83
+
84
+ get dropFileEl() {
85
+ return this.renderRoot.querySelector('#dropfile');
86
+ }
87
+
88
+ async handleFileChange(e) {
89
+ const file = e.detail.value;
90
+ const token = ++this._inspectToken;
91
+ this.imageInfo = null;
92
+
93
+ if (!file) {
94
+ return;
95
+ }
96
+
97
+ let info;
98
+ try {
99
+ info = await readImageFile(file);
100
+ } catch (error) {
101
+ if (token !== this._inspectToken) return;
102
+ const message = error.code === 'EMPTY_FILE'
103
+ ? this.translations.image_empty_file
104
+ : this.translations.image_invalid_file;
105
+ this.rejectFile(message);
106
+ return;
107
+ }
108
+
109
+ if (token !== this._inspectToken) return;
110
+
111
+ const { valid, errors } = validateImageFile(info, {
112
+ maxFileSize: this.maxFileSize,
113
+ minWidth: this.minWidth,
114
+ maxWidth: this.maxWidth,
115
+ minHeight: this.minHeight,
116
+ maxHeight: this.maxHeight,
117
+ });
118
+
119
+ if (!valid) {
120
+ this.rejectFile(buildValidationMessages(errors, this.translations).join('. '));
121
+ return;
122
+ }
123
+
124
+ this.imageInfo = {
125
+ width: info.width,
126
+ height: info.height,
127
+ size: info.size,
128
+ thumbnail: createThumbnail(info.bitmap),
129
+ };
130
+ }
131
+
132
+ rejectFile(message) {
133
+ this.dropFileEl.errored = true;
134
+ this.dropFileEl.message = message;
135
+ this.dropFileEl.clear();
136
+ }
39
137
  }
package/lib/i18n/en.js CHANGED
@@ -52,6 +52,20 @@ export const translations = {
52
52
  select_file: "Select file",
53
53
  selected_file: "Selected file",
54
54
  extension_allowed: "Only this file extensions are allowed: ",
55
+ image_empty_file: "The selected file is empty",
56
+ image_invalid_file: "The selected file is not a valid image or it's corrupted",
57
+ image_file_too_large: (maxSizeLabel) => `The image exceeds the maximum allowed size (${maxSizeLabel})`,
58
+ image_width_error: (minWidth, maxWidth) => minWidth != null && maxWidth != null
59
+ ? `Image width must be between ${minWidth} and ${maxWidth}px`
60
+ : minWidth != null
61
+ ? `Image width must be at least ${minWidth}px`
62
+ : `Image width must be at most ${maxWidth}px`,
63
+ image_height_error: (minHeight, maxHeight) => minHeight != null && maxHeight != null
64
+ ? `Image height must be between ${minHeight} and ${maxHeight}px`
65
+ : minHeight != null
66
+ ? `Image height must be at least ${minHeight}px`
67
+ : `Image height must be at most ${maxHeight}px`,
68
+ image_dimensions_summary: (width, height, sizeLabel) => `${width} × ${height} px · ${sizeLabel}`,
55
69
  confirm_submit_title: "Confirm submission",
56
70
  confirm_submit_text: "Are you sure you want to submit this form?",
57
71
  add_relation_label: "Add",
package/lib/i18n/es.js CHANGED
@@ -52,6 +52,20 @@ export const translations = {
52
52
  select_file: "Seleccionar archivo",
53
53
  selected_file: "Archivo seleccionado",
54
54
  extension_allowed: "Solo se permiten estas extensiones: ",
55
+ image_empty_file: "El archivo seleccionado está vacío",
56
+ image_invalid_file: "El archivo seleccionado no es una imagen válida o está corrupto",
57
+ image_file_too_large: (maxSizeLabel) => `La imagen supera el tamaño máximo permitido (${maxSizeLabel})`,
58
+ image_width_error: (minWidth, maxWidth) => minWidth != null && maxWidth != null
59
+ ? `El ancho de la imagen debe estar entre ${minWidth} y ${maxWidth}px`
60
+ : minWidth != null
61
+ ? `El ancho de la imagen debe ser de al menos ${minWidth}px`
62
+ : `El ancho de la imagen debe ser como máximo de ${maxWidth}px`,
63
+ image_height_error: (minHeight, maxHeight) => minHeight != null && maxHeight != null
64
+ ? `El alto de la imagen debe estar entre ${minHeight} y ${maxHeight}px`
65
+ : minHeight != null
66
+ ? `El alto de la imagen debe ser de al menos ${minHeight}px`
67
+ : `El alto de la imagen debe ser como máximo de ${maxHeight}px`,
68
+ image_dimensions_summary: (width, height, sizeLabel) => `${width} × ${height} px · ${sizeLabel}`,
55
69
  confirm_submit_title: "Confirmar envío",
56
70
  confirm_submit_text: "¿Estás seguro de que deseas enviar este formulario?",
57
71
  add_relation_label: "Añadir",
@@ -0,0 +1,7 @@
1
+ export class InvalidImageFileError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = 'InvalidImageFileError';
5
+ this.code = code;
6
+ }
7
+ }
@@ -0,0 +1,16 @@
1
+ import { formatFileSize } from './formatFileSize.js';
2
+
3
+ export function buildValidationMessages(errors, translations) {
4
+ return errors.map(error => {
5
+ switch (error.code) {
6
+ case 'FILE_TOO_LARGE':
7
+ return translations.image_file_too_large(formatFileSize(error.maxFileSize));
8
+ case 'WIDTH_OUT_OF_RANGE':
9
+ return translations.image_width_error(error.minWidth, error.maxWidth);
10
+ case 'HEIGHT_OUT_OF_RANGE':
11
+ return translations.image_height_error(error.minHeight, error.maxHeight);
12
+ default:
13
+ return '';
14
+ }
15
+ });
16
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildValidationMessages } from './buildValidationMessages.js';
4
+
5
+ const translations = {
6
+ image_file_too_large: (maxSizeLabel) => `Too large, max ${maxSizeLabel}`,
7
+ image_width_error: (min, max) => `Width must be between ${min} and ${max}`,
8
+ image_height_error: (min, max) => `Height must be between ${min} and ${max}`,
9
+ };
10
+
11
+ test('builds message for FILE_TOO_LARGE using formatted size', () => {
12
+ const messages = buildValidationMessages([{ code: 'FILE_TOO_LARGE', maxFileSize: 2048 }], translations);
13
+ assert.deepEqual(messages, ['Too large, max 2.0 KB']);
14
+ });
15
+
16
+ test('builds message for WIDTH_OUT_OF_RANGE', () => {
17
+ const messages = buildValidationMessages(
18
+ [{ code: 'WIDTH_OUT_OF_RANGE', minWidth: 200, maxWidth: 1000 }],
19
+ translations
20
+ );
21
+ assert.deepEqual(messages, ['Width must be between 200 and 1000']);
22
+ });
23
+
24
+ test('builds message for HEIGHT_OUT_OF_RANGE', () => {
25
+ const messages = buildValidationMessages(
26
+ [{ code: 'HEIGHT_OUT_OF_RANGE', minHeight: 200, maxHeight: 1000 }],
27
+ translations
28
+ );
29
+ assert.deepEqual(messages, ['Height must be between 200 and 1000']);
30
+ });
31
+
32
+ test('builds messages for multiple errors in order', () => {
33
+ const messages = buildValidationMessages(
34
+ [
35
+ { code: 'FILE_TOO_LARGE', maxFileSize: 1024 },
36
+ { code: 'WIDTH_OUT_OF_RANGE', minWidth: 100, maxWidth: 200 },
37
+ ],
38
+ translations
39
+ );
40
+ assert.equal(messages.length, 2);
41
+ });
@@ -0,0 +1,13 @@
1
+ export function createThumbnail(bitmap, maxSize = 120) {
2
+ const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
3
+ const width = Math.round(bitmap.width * scale);
4
+ const height = Math.round(bitmap.height * scale);
5
+
6
+ const canvas = document.createElement('canvas');
7
+ canvas.width = width;
8
+ canvas.height = height;
9
+ const context = canvas.getContext('2d');
10
+ context.drawImage(bitmap, 0, 0, width, height);
11
+
12
+ return canvas.toDataURL('image/png');
13
+ }
@@ -0,0 +1,9 @@
1
+ export function formatFileSize(bytes) {
2
+ if (bytes === 0) {
3
+ return '0 B';
4
+ }
5
+ const units = ['B', 'KB', 'MB', 'GB'];
6
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
7
+ const value = bytes / Math.pow(1024, exponent);
8
+ return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
9
+ }
@@ -0,0 +1,23 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { formatFileSize } from './formatFileSize.js';
4
+
5
+ test('formats 0 bytes', () => {
6
+ assert.equal(formatFileSize(0), '0 B');
7
+ });
8
+
9
+ test('formats bytes below 1 KB', () => {
10
+ assert.equal(formatFileSize(512), '512 B');
11
+ });
12
+
13
+ test('formats kilobytes', () => {
14
+ assert.equal(formatFileSize(2048), '2.0 KB');
15
+ });
16
+
17
+ test('formats megabytes', () => {
18
+ assert.equal(formatFileSize(2_411_724), '2.3 MB');
19
+ });
20
+
21
+ test('formats gigabytes', () => {
22
+ assert.equal(formatFileSize(1_610_612_736), '1.5 GB');
23
+ });
@@ -0,0 +1,23 @@
1
+ import { InvalidImageFileError } from './InvalidImageFileError.js';
2
+
3
+ export async function readImageFile(file) {
4
+ if (!file || file.size === 0) {
5
+ throw new InvalidImageFileError('EMPTY_FILE', 'The file is empty');
6
+ }
7
+
8
+ let bitmap;
9
+ try {
10
+ bitmap = await createImageBitmap(file);
11
+ } catch (error) {
12
+ throw new InvalidImageFileError('DECODE_ERROR', 'The file could not be read as a valid image');
13
+ }
14
+
15
+ return {
16
+ name: file.name,
17
+ size: file.size,
18
+ type: file.type,
19
+ width: bitmap.width,
20
+ height: bitmap.height,
21
+ bitmap,
22
+ };
23
+ }
@@ -0,0 +1,18 @@
1
+ export function validateImageFile(info, constraints = {}) {
2
+ const { maxFileSize, minWidth, maxWidth, minHeight, maxHeight } = constraints;
3
+ const errors = [];
4
+
5
+ if (maxFileSize != null && info.size > maxFileSize) {
6
+ errors.push({ code: 'FILE_TOO_LARGE', maxFileSize, actual: info.size });
7
+ }
8
+
9
+ if ((minWidth != null && info.width < minWidth) || (maxWidth != null && info.width > maxWidth)) {
10
+ errors.push({ code: 'WIDTH_OUT_OF_RANGE', minWidth, maxWidth, actual: info.width });
11
+ }
12
+
13
+ if ((minHeight != null && info.height < minHeight) || (maxHeight != null && info.height > maxHeight)) {
14
+ errors.push({ code: 'HEIGHT_OUT_OF_RANGE', minHeight, maxHeight, actual: info.height });
15
+ }
16
+
17
+ return { valid: errors.length === 0, errors };
18
+ }
@@ -0,0 +1,54 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateImageFile } from './validateImageFile.js';
4
+
5
+ const baseInfo = { size: 1000, width: 800, height: 600 };
6
+
7
+ test('valid when no constraints are set', () => {
8
+ const result = validateImageFile(baseInfo, {});
9
+ assert.equal(result.valid, true);
10
+ assert.deepEqual(result.errors, []);
11
+ });
12
+
13
+ test('rejects files over maxFileSize', () => {
14
+ const result = validateImageFile(baseInfo, { maxFileSize: 500 });
15
+ assert.equal(result.valid, false);
16
+ assert.equal(result.errors[0].code, 'FILE_TOO_LARGE');
17
+ assert.equal(result.errors[0].maxFileSize, 500);
18
+ assert.equal(result.errors[0].actual, 1000);
19
+ });
20
+
21
+ test('rejects width below minWidth', () => {
22
+ const result = validateImageFile(baseInfo, { minWidth: 1000 });
23
+ assert.equal(result.valid, false);
24
+ assert.equal(result.errors[0].code, 'WIDTH_OUT_OF_RANGE');
25
+ });
26
+
27
+ test('rejects width above maxWidth', () => {
28
+ const result = validateImageFile(baseInfo, { maxWidth: 400 });
29
+ assert.equal(result.valid, false);
30
+ assert.equal(result.errors[0].code, 'WIDTH_OUT_OF_RANGE');
31
+ });
32
+
33
+ test('rejects height out of range', () => {
34
+ const result = validateImageFile(baseInfo, { minHeight: 700, maxHeight: 900 });
35
+ assert.equal(result.valid, false);
36
+ assert.equal(result.errors[0].code, 'HEIGHT_OUT_OF_RANGE');
37
+ });
38
+
39
+ test('accumulates multiple errors', () => {
40
+ const result = validateImageFile(baseInfo, { maxFileSize: 10, maxWidth: 10, maxHeight: 10 });
41
+ assert.equal(result.valid, false);
42
+ assert.equal(result.errors.length, 3);
43
+ });
44
+
45
+ test('passes when dimensions and size are within range', () => {
46
+ const result = validateImageFile(baseInfo, {
47
+ maxFileSize: 2000,
48
+ minWidth: 200,
49
+ maxWidth: 1000,
50
+ minHeight: 200,
51
+ maxHeight: 1000,
52
+ });
53
+ assert.equal(result.valid, true);
54
+ });
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@dile/crud",
3
- "version": "1.14.6",
3
+ "version": "1.14.8",
4
4
  "description": "Components to create a generic crud system based on Web Components and Lit",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "scripts": {
7
8
  "test": "echo \"Error: no test specified\" && exit 1"
8
9
  },
@@ -24,12 +25,12 @@
24
25
  },
25
26
  "homepage": "https://dile-components.com/",
26
27
  "dependencies": {
27
- "@dile/ui": "^2.26.0",
28
+ "@dile/ui": "^2.27.0",
28
29
  "axios": "^1.16.0",
29
30
  "lit": "^2.7.0 || ^3.0.0"
30
31
  },
31
32
  "publishConfig": {
32
33
  "access": "public"
33
34
  },
34
- "gitHead": "90ebde5b908aab4093afeee3bda87ef3ad85904e"
35
+ "gitHead": "5c04395f81c01fd289869b1b99d427d482c516a5"
35
36
  }