@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.
- package/addon/components/custom-field/input.hbs +1 -1
- package/addon/components/custom-field/input.js +61 -24
- package/addon/components/custom-field/options-input.hbs +1 -1
- package/addon/components/file.hbs +52 -49
- package/addon/components/file.js +5 -36
- package/addon/components/layout/resource/panel.js +22 -12
- package/addon/components/modals/resend-verification-email.hbs +4 -19
- package/addon/components/model-coordinates-input.hbs +1 -1
- package/addon/components/tab-navigation.hbs +5 -1
- package/addon/styles/components/file.css +32 -18
- package/addon/styles/layout/next.css +6 -2
- package/addon/utils/is-image-file.js +101 -0
- package/app/utils/is-image-file.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
81
|
-
type
|
|
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">
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
</
|
|
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>
|
package/addon/components/file.js
CHANGED
|
@@ -1,43 +1,12 @@
|
|
|
1
1
|
import Component from '@glimmer/component';
|
|
2
|
-
import
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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
|
-
<
|
|
4
|
-
<div class="
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
.file-icon
|
|
11
|
-
|
|
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
|
|
17
|
-
.file-icon
|
|
18
|
-
.file-icon
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
16
|
+
.file-icon-docx > svg,
|
|
17
|
+
.file-icon-doc > svg {
|
|
18
|
+
@apply text-blue-600;
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
.file-icon
|
|
27
|
-
|
|
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-
|
|
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.
|
|
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",
|