@fleetbase/ember-ui 0.3.8 → 0.3.10

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.
@@ -74,7 +74,7 @@
74
74
  @onFileAdded={{this.onFileAddedHandler}}
75
75
  >
76
76
  <a tabindex={{0}} class="btn btn-default btn-xs cursor-pointer">
77
- <FaIcon @icon="upload" @size="sm" class="mr-2" />{{t "common.select-file"}}
77
+ <FaIcon @icon="upload" @size="sm" class="mr-2" />{{t "common.select-field" field=(t "common.file")}}
78
78
  </a>
79
79
  </FileUpload>
80
80
  {{#if this.file}}
@@ -16,27 +16,53 @@ export default class CustomFieldInputComponent extends Component {
16
16
  @tracked value;
17
17
  @tracked file;
18
18
  @tracked uploadedFile;
19
- acceptedFileTypes = [
20
- 'application/vnd.ms-excel',
21
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
22
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
23
- 'application/msword',
24
- 'application/pdf',
25
- 'application/x-pdf',
26
- 'image/jpeg',
27
- 'image/png',
28
- 'image/gif',
29
- 'image/webp',
30
- 'video/mp4',
31
- 'video/quicktime',
32
- 'video/x-msvideo',
33
- 'video/x-flv',
34
- 'video/x-ms-wmv',
35
- 'audio/mpeg',
36
- 'video/x-msvideo',
37
- 'application/zip',
38
- 'application/x-tar',
39
- ];
19
+
20
+ get acceptedFileTypes() {
21
+ return [
22
+ // Excel
23
+ 'application/vnd.ms-excel',
24
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
25
+ 'application/vnd.ms-excel.sheet.macroenabled.12',
26
+ // Word / PowerPoint
27
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
29
+ 'application/msword',
30
+ // PDF
31
+ 'application/pdf',
32
+ 'application/x-pdf',
33
+ // Images
34
+ 'image/jpeg',
35
+ 'image/png',
36
+ 'image/gif',
37
+ 'image/webp',
38
+ // Video
39
+ 'video/mp4',
40
+ 'video/quicktime',
41
+ 'video/x-msvideo',
42
+ 'video/x-flv',
43
+ 'video/x-ms-wmv',
44
+ // Audio
45
+ 'audio/mpeg',
46
+ // Archives
47
+ 'application/zip',
48
+ 'application/x-tar',
49
+ // Json
50
+ 'application/json',
51
+ 'text/json',
52
+ 'application/x-json',
53
+ // Text documents
54
+ 'text/plain',
55
+ 'text/markdown',
56
+ 'application/rtf',
57
+ 'text/csv',
58
+ 'text/tab-separated-values',
59
+ 'text/html',
60
+ 'application/xml',
61
+ 'text/xml',
62
+ 'application/x-yaml',
63
+ 'text/yaml',
64
+ ];
65
+ }
40
66
 
41
67
  /**
42
68
  * A map defining the available custom field types and their corresponding components.
@@ -65,7 +91,7 @@ export default class CustomFieldInputComponent extends Component {
65
91
  }
66
92
  }
67
93
 
68
- @action onFileAddedHandler(file) {
94
+ @action async onFileAddedHandler(file) {
69
95
  // since we have dropzone and upload button within dropzone validate the file state first
70
96
  // as this method can be called twice from both functions
71
97
  if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) return;
@@ -73,12 +99,23 @@ export default class CustomFieldInputComponent extends Component {
73
99
  // set file for progress state
74
100
  this.file = file;
75
101
 
102
+ // resolve subject if necessary
103
+ const subject = await this.subject;
104
+
105
+ let path = `uploads/${this.extension ?? 'cf-files'}/${this.customField.id}`;
106
+ let type = `custom_field_file`;
107
+
108
+ if (subject) {
109
+ path = `uploads/${this.extension ?? 'cf-files'}/${getModelName(subject)}-cf-files`;
110
+ type = `${underscore(getModelName(subject))}_file`;
111
+ }
112
+
76
113
  // Queue and upload immediatley
77
114
  this.fetch.uploadFile.perform(
78
115
  file,
79
116
  {
80
- path: `uploads/${this.extension}/${getModelName(this.subject)}-cf-files`,
81
- type: `${underscore(getModelName(this.subject))}_file`,
117
+ path,
118
+ type,
82
119
  },
83
120
  (uploadedFile) => {
84
121
  this.file = undefined;
@@ -1,6 +1,6 @@
1
1
  <div class="rounded-lg border border-gray-200 dark:border-gray-700" ...attributes>
2
2
  <div class="flex flex-row items-center justify-between px-4 py-2">
3
- <div class="mr-2 text-sm">{{t "fleet-ops.component.custom-field-form-panel.field-options"}}</div>
3
+ <div class="mr-2 text-sm">Field Options</div>
4
4
  <Button @type="magic" @icon="plus" @text="Add new option" @size="xs" @onClick={{this.addOption}} />
5
5
  </div>
6
6
  {{#each-in this.options as |index option|}}
@@ -1,56 +1,59 @@
1
- <div class="x-fleetbase-file" ...attributes>
2
- <div class="x-fleetbase-file-wrapper">
3
- <div class="x-fleetbase-file-actions">
4
- <DropdownButton
5
- @dropdownId="x-fleetbase-file-actions-dropdown"
6
- @icon={{or @dropdownIcon "ellipsis"}}
7
- @iconSize="xs"
8
- @iconPrefix={{@dropdownButtonIconPrefix}}
9
- @text={{@dropdownButtonText}}
10
- @size="xs"
11
- @horizontalPosition="left"
12
- @calculatePosition={{@dropdownButtonCalculatePosition}}
13
- @renderInPlace={{or @dropdownButtonRenderInPlace true}}
14
- @wrapperClass={{concat @dropdownButtonWrapperClass " " "next-nav-item-dropdown-button"}}
15
- @triggerClass={{@dropdownButtonTriggerClass}}
16
- @registerAPI={{@registerAPI}}
17
- @onInsert={{this.onDropdownButtonInsert}}
18
- as |dd|
19
- >
20
- <div class="next-dd-menu mt-0i" role="menu" aria-orientation="vertical" aria-labelledby="user-menu">
21
- <div class="px-1">
22
- <div class="text-sm flex flex-row items-center px-3 py-1 rounded-md my-1 text-gray-800 dark:text-gray-300">
23
- {{t "component.file.dropdown-label"}}
24
- </div>
25
- </div>
26
- <div class="next-dd-menu-seperator"></div>
27
- <div role="group" class="px-1">
1
+ <div
2
+ class="group relative w-32 h-40 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md transition-all"
3
+ ...attributes
4
+ >
5
+ <div class="relative w-full h-32 bg-gray-50 dark:bg-gray-900 flex items-center justify-center overflow-hidden rounded-t-lg">
6
+ {{#if this.isImage}}
7
+ <Image src={{@file.url}} alt={{@file.original_filename}} class="w-full h-full object-cover" />
8
+ {{else}}
9
+ <div class="flex items-center justify-center w-full h-full">
10
+ <FileIcon @file={{@file}} @hideExtension={{true}} @iconSize="3x" />
11
+ </div>
12
+ {{/if}}
13
+ </div>
28
14
 
29
- <a href="javascript:;" role="menuitem" class="next-dd-item text-danger" {{on "click" (fn this.onDropdownItemClick "onDelete" dd)}}>
30
- <span class="mr-1">
31
- <FaIcon @icon="trash" @prefix={{@dropdownButtonIconPrefix}} />
32
- </span>
33
- {{t "common.delete"}}
34
- </a>
15
+ <div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
16
+ <DropdownButton
17
+ @dropdownId="file-actions-{{@file.id}}"
18
+ @icon={{or @dropdownIcon "ellipsis-v"}}
19
+ @iconSize="sm"
20
+ @iconPrefix={{@dropdownButtonIconPrefix}}
21
+ @text={{@dropdownButtonText}}
22
+ @size="xs"
23
+ @horizontalPosition="left"
24
+ @calculatePosition={{@dropdownButtonCalculatePosition}}
25
+ @renderInPlace={{or @dropdownButtonRenderInPlace true}}
26
+ @wrapperClass={{concat @dropdownButtonWrapperClass " " "next-nav-item-dropdown-button"}}
27
+ @triggerClass={{@dropdownButtonTriggerClass}}
28
+ @registerAPI={{@registerAPI}}
29
+ @onInsert={{this.onDropdownButtonInsert}}
30
+ as |dd|
31
+ >
32
+ <div class="next-dd-menu mt-0" role="menu">
33
+ <div class="px-1 pt-2">
34
+ <div class="text-xs px-2 text-gray-500 dark:text-gray-400 font-medium">
35
+ {{t "component.file.dropdown-label"}}
35
36
  </div>
36
37
  </div>
37
- </DropdownButton>
38
- </div>
39
- <div class="flex flex-1 flex-col justify-between items-center">
40
- <div class="flex-1">
41
- {{#if this.isImage}}
42
- <Image src={{@file.url}} alt={{@file.original_filename}} class="x-fleetbase-file-preview rounded-md shadow-sm" />
43
- {{else}}
44
- <div class="x-fleetbase-file-preview">
45
- <FileIcon @file={{@file}} @hideExtension={{true}} @iconSize="2xl" />
46
- </div>
47
- {{/if}}
48
- </div>
49
- <div class="flex-1 overflow-hidden flex flex-col items-center justify-end">
50
- <div class="x-fleetbase-file-name">
51
- {{truncate-filename @file.original_filename}}
38
+ <div class="next-dd-menu-seperator"></div>
39
+ <div role="group" class="px-1">
40
+ <a
41
+ href="javascript:;"
42
+ role="menuitem"
43
+ class="next-dd-item xs-text text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
44
+ {{on "click" (dropdown-fn dd @onDelete this.file)}}
45
+ >
46
+ <FaIcon @icon="trash" @size="sm" class="mr-2" @prefix={{@dropdownButtonIconPrefix}} />
47
+ {{t "common.delete"}}
48
+ </a>
52
49
  </div>
53
50
  </div>
54
- </div>
51
+ </DropdownButton>
52
+ </div>
53
+
54
+ <div class="flex items-center justify-between px-2 py-1.5 border-t border-gray-100 dark:border-gray-700">
55
+ <span class="text-xs text-gray-700 dark:text-gray-300 truncate font-medium">
56
+ {{truncate-filename @file.original_filename}}
57
+ </span>
55
58
  </div>
56
59
  </div>
@@ -1,43 +1,12 @@
1
1
  import Component from '@glimmer/component';
2
- import { tracked } from '@glimmer/tracking';
3
- import { action } from '@ember/object';
2
+ import isImageFile from '../utils/is-image-file';
4
3
 
5
4
  export default class FileComponent extends Component {
6
- @tracked file;
7
- @tracked isImage = false;
8
-
9
- constructor(owner, { file }) {
10
- super(...arguments);
11
-
12
- this.file = file;
13
- this.isImage = this.isImageFile(file);
14
- }
15
-
16
- @action onDropdownItemClick(action, dd) {
17
- if (typeof dd.actions === 'object' && typeof dd.actions.close === 'function') {
18
- dd.actions.close();
19
- }
20
-
21
- if (typeof this.args[action] === 'function') {
22
- this.args[action](this.file);
23
- }
5
+ get file() {
6
+ return this.args.file;
24
7
  }
25
8
 
26
- isImageFile(file) {
27
- if (!file || (!file.original_filename && !file.url && !file.path)) {
28
- return false;
29
- }
30
-
31
- const filename = file.original_filename || file.url || file.path;
32
- const extensionMatch = filename.match(/\.(.+)$/);
33
-
34
- if (!extensionMatch) {
35
- return false;
36
- }
37
-
38
- const extension = extensionMatch[1].toLowerCase();
39
- const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'];
40
-
41
- return imageExtensions.includes(extension);
9
+ get isImage() {
10
+ return isImageFile(this.file);
42
11
  }
43
12
  }
@@ -18,12 +18,27 @@ export default class LayoutResourcePanelComponent extends Component {
18
18
  @service hostRouter;
19
19
  @service contextPanel;
20
20
  @tracked overlayContext;
21
- @tracked resource;
22
- @tracked controller;
23
- @tracked width = '600px';
24
- @tracked isResizable = true;
25
- // fleet-ops is default auth schema
26
- @tracked authSchema = 'fleet-ops';
21
+
22
+ // Mirror args (reactive)
23
+ get resource() {
24
+ return this.args.resource;
25
+ }
26
+
27
+ get controller() {
28
+ return this.args.controller;
29
+ }
30
+
31
+ get width() {
32
+ return this.args.width ?? '600px';
33
+ }
34
+
35
+ get isResizable() {
36
+ return this.args.isResizable ?? true;
37
+ }
38
+
39
+ get authSchema() {
40
+ return this.args.authSchema ?? 'fleet-ops';
41
+ }
27
42
 
28
43
  get resourceName() {
29
44
  return this.resource?.name ?? this.resource?.displayName ?? this.resource?.display_name;
@@ -46,13 +61,8 @@ export default class LayoutResourcePanelComponent extends Component {
46
61
  return 'Save Changes';
47
62
  }
48
63
 
49
- constructor(owner, { resource, controller, width = '600px', isResizable = true, authSchema = 'fleet-ops' }) {
64
+ constructor() {
50
65
  super(...arguments);
51
- this.resource = resource;
52
- this.controller = controller;
53
- this.width = width;
54
- this.isResizable = isResizable;
55
- this.authSchema = authSchema;
56
66
  applyContextComponentArguments(this);
57
67
  }
58
68
 
@@ -1,24 +1,9 @@
1
1
  <Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
2
2
  <div class="modal-body-container">
3
- <div class="bg-blue-50 rounded shadow-sm border-l-4 border-blue-400 p-4 mb-5">
4
- <div class="flex">
5
- <div class="flex-shrink-0">
6
- <svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
7
- <path
8
- fill-rule="evenodd"
9
- d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
10
- clip-rule="evenodd"
11
- ></path>
12
- </svg>
13
- </div>
14
- <div class="ml-3 flex items-center">
15
- <span class="font-extrabold text-blue-800">Verify your email address</span>
16
- </div>
17
- </div>
18
- <div class="py-3">
19
- <p class="text-blue-700">Re-verify your email address and click Send to continue.</p>
20
- </div>
21
- </div>
3
+ <InfoBlock @type="info" @icon="lightbulb" @iconSize="lg" class="mb-5">
4
+ <div class="font-extrabold">Verify your email address.</div>
5
+ <div>Re-verify your email address and click Send to continue.</div>
6
+ </InfoBlock>
22
7
 
23
8
  <InputGroup
24
9
  @type="email"
@@ -1,6 +1,6 @@
1
1
  <CoordinatesInput
2
2
  @value={{if @locationProperty (get @model @locationProperty) @model.location}}
3
- @onChange={{this.updatePlaceCoordinates}}
3
+ @onChange={{this.updateCoordinates}}
4
4
  @onGeocode={{this.onAutocomplete}}
5
5
  @onUpdatedFromMap={{perform this.reverseGeocode}}
6
6
  @onInit={{this.setCoordinatesInput}}
@@ -2,11 +2,15 @@
2
2
  <div class="tab-navigation {{@containerClass}}" data-style={{or @style "github"}} data-size={{or @size "md"}}>
3
3
  <div class="tab-list justify-between {{@tablistClass}}" role="tablist">
4
4
  <div class="flex flex-row items-center" role="tablist">
5
+ {{#if (has-block "title")}}
6
+ <div id="tab-navigation-title" class="tab-navigation-title {{@tabTitleWrapperClass}}">
7
+ {{yield to="title"}}
8
+ </div>
9
+ {{/if}}
5
10
  {{#if (has-block "tabs")}}
6
11
  {{yield this to="tabs"}}
7
12
  {{else if @tabs}}
8
13
  {{#each this.enhancedTabs as |tab|}}
9
-
10
14
  {{#if tab.route}}
11
15
  <LinkTo
12
16
  @route={{tab.route}}
@@ -1,28 +1,42 @@
1
1
  .file-icon {
2
- display: flex;
3
- align-items: center;
4
- -webkit-user-select: none;
5
- -moz-user-select: none;
6
- user-select: none;
2
+ @apply flex flex-col items-center justify-center;
7
3
  }
8
4
 
9
- .file-icon.file-icon-xlsb,
10
- .file-icon.file-icon-xlsm,
11
- .file-icon.file-icon-xlsx,
12
- .file-icon.file-icon-xls {
13
- color: #3b8530;
5
+ /* Optional: Custom file type colors */
6
+ .file-icon-pdf > svg {
7
+ @apply text-red-500;
14
8
  }
15
9
 
16
- .file-icon.file-icon-doc,
17
- .file-icon.file-icon-docx,
18
- .file-icon.file-icon-docm {
19
- color: #2e5ea7;
10
+ .file-icon-xlsx > svg,
11
+ .file-icon-xls > svg,
12
+ .file-icon-csv > svg {
13
+ @apply text-green-600;
20
14
  }
21
15
 
22
- .file-icon.file-icon-pdf {
23
- color: #ad311a;
16
+ .file-icon-docx > svg,
17
+ .file-icon-doc > svg {
18
+ @apply text-blue-600;
24
19
  }
25
20
 
26
- .file-icon .file-extension {
27
- @apply mx-1 capitalize font-semibold;
21
+ .file-icon-pptx > svg,
22
+ .file-icon-ppt > svg {
23
+ @apply text-orange-500;
24
+ }
25
+
26
+ .file-icon-zip > svg,
27
+ .file-icon-rar > svg {
28
+ @apply text-yellow-600;
29
+ }
30
+
31
+ /* Smooth image loading */
32
+ .file-preview-image {
33
+ @apply transition-opacity duration-200;
34
+ }
35
+
36
+ .file-preview-image[loading] {
37
+ @apply opacity-0;
38
+ }
39
+
40
+ .file-preview-image[loaded] {
41
+ @apply opacity-100;
28
42
  }
@@ -1691,6 +1691,10 @@ body[data-theme='dark']
1691
1691
  @apply text-sm flex flex-row items-center px-3 py-0.5 rounded-md my-1;
1692
1692
  }
1693
1693
 
1694
+ .next-dd-menu .next-dd-item.xs-text {
1695
+ @apply text-xs;
1696
+ }
1697
+
1694
1698
  .next-dd-menu .next-dd-item > .next-nav-item-icon-container {
1695
1699
  width: 1rem;
1696
1700
  }
@@ -2053,7 +2057,7 @@ body[data-theme='light'] .next-table-wrapper table tfoot tr td {
2053
2057
  }
2054
2058
 
2055
2059
  .field-info-container > .field-name {
2056
- @apply font-semibold text-gray-700;
2060
+ @apply text-[11px] tracking-wide uppercase font-semibold text-gray-700;
2057
2061
  }
2058
2062
 
2059
2063
  .field-info-container > .field-value {
@@ -2061,7 +2065,7 @@ body[data-theme='light'] .next-table-wrapper table tfoot tr td {
2061
2065
  }
2062
2066
 
2063
2067
  body[data-theme='dark'] .field-info-container > .field-name {
2064
- @apply text-gray-400;
2068
+ @apply text-gray-500;
2065
2069
  }
2066
2070
 
2067
2071
  body[data-theme='dark'] .field-info-container > .field-value {
@@ -0,0 +1,101 @@
1
+ export default function isImageFile(file) {
2
+ if (!file) return false;
3
+
4
+ // MIME (most reliable if provided)
5
+ const mime = (file.content_type || file.type || '').toLowerCase();
6
+ if (mime.startsWith('image/')) return true; // e.g., "image/png"
7
+ // Some services mislabel images as octet-stream; fall through if so.
8
+
9
+ // data: URL (base64 inline)
10
+ const url = file.url || file.path || '';
11
+ if (typeof url === 'string' && url.startsWith('data:image/')) return true;
12
+
13
+ // Extension check (strip query/fragment, pick last segment)
14
+ const name = file.original_filename || file.path || file.url || '';
15
+ const ext = getFileExtension(name);
16
+ if (ext) {
17
+ const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'jfif', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'svg', 'heic', 'heif', 'avif', 'ico', 'cur']);
18
+ if (IMAGE_EXTS.has(ext)) return true;
19
+ }
20
+
21
+ // Signature sniffing if you have bytes/base64
22
+ // This helps when MIME & ext are missing/misleading.
23
+ const bytes = file.bytes || file.arrayBuffer || file.buffer;
24
+ const base64 = file.base64;
25
+ if (bytes || base64) {
26
+ /* eslint-disable no-empty */
27
+ try {
28
+ const u8 = toUint8(bytes, base64);
29
+ if (u8) return looksLikeImageBytes(u8);
30
+ } catch (_) {}
31
+ }
32
+
33
+ return false;
34
+ }
35
+
36
+ export function getFileExtension(str) {
37
+ if (!str || typeof str !== 'string') return '';
38
+ // Fast path for data URLs
39
+ if (str.startsWith('data:image/')) return 'dataurl';
40
+ // Strip query/fragment
41
+ const clean = str.split('?')[0].split('#')[0];
42
+ // Handle full URLs safely if possible
43
+ try {
44
+ const u = new URL(clean);
45
+ str = u.pathname;
46
+ } catch (_) {
47
+ str = clean;
48
+ }
49
+ const last = str.split('/').pop() || '';
50
+ const dot = last.lastIndexOf('.');
51
+ if (dot <= 0 || dot === last.length - 1) return '';
52
+ return last.slice(dot + 1).toLowerCase();
53
+ }
54
+
55
+ // convert provided bytes/base64 into Uint8Array
56
+ export function toUint8(bytes, base64) {
57
+ if (bytes instanceof Uint8Array) return bytes;
58
+ if (bytes && bytes.byteLength != null) return new Uint8Array(bytes);
59
+ if (typeof base64 === 'string') {
60
+ if (base64.startsWith('data:')) base64 = base64.split(',')[1] || '';
61
+ const bin = atob(base64);
62
+ const u8 = new Uint8Array(bin.length);
63
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
64
+ return u8;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ // quick magic-number checks for common formats
70
+ export function looksLikeImageBytes(u8) {
71
+ if (u8.length < 12) return false;
72
+
73
+ // JPEG: FF D8 FF
74
+ if (u8[0] === 0xff && u8[1] === 0xd8 && u8[2] === 0xff) return true;
75
+
76
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
77
+ if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47 && u8[4] === 0x0d && u8[5] === 0x0a && u8[6] === 0x1a && u8[7] === 0x0a) return true;
78
+
79
+ // GIF: "GIF87a"/"GIF89a"
80
+ if (u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x38 && (u8[4] === 0x37 || u8[4] === 0x39) && u8[5] === 0x61) return true;
81
+
82
+ // WEBP: "RIFF" .... "WEBP"
83
+ if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 && u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) return true;
84
+
85
+ // BMP: "BM"
86
+ if (u8[0] === 0x42 && u8[1] === 0x4d) return true;
87
+
88
+ // TIFF: "II*" or "MM*"
89
+ if ((u8[0] === 0x49 && u8[1] === 0x49 && u8[2] === 0x2a && u8[3] === 0x00) || (u8[0] === 0x4d && u8[1] === 0x4d && u8[2] === 0x00 && u8[3] === 0x2a)) return true;
90
+
91
+ // HEIC/AVIF (ISOBMFF): "ftyp" box with known brands
92
+ // bytes 4..7: 'ftyp'
93
+ const isFtyp = u8[4] === 0x66 && u8[5] === 0x74 && u8[6] === 0x79 && u8[7] === 0x70;
94
+ if (isFtyp) {
95
+ const brand = String.fromCharCode(u8[8], u8[9], u8[10], u8[11]).toLowerCase();
96
+ if (['heic', 'heix', 'mif1', 'hevc', 'avif', 'avis'].includes(brand)) return true;
97
+ }
98
+
99
+ // SVG can’t be reliably sniffed with bytes (it’s XML). Rely on MIME/extension.
100
+ return false;
101
+ }
@@ -0,0 +1 @@
1
+ export { default } from '@fleetbase/ember-ui/utils/is-image-file';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/ember-ui",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.",
5
5
  "keywords": [
6
6
  "fleetbase-ui",