@energysage/es-ds-components 0.0.1 → 3.0.0-alpha.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/.eslintignore +9 -0
- package/.eslintrc.json +3 -0
- package/.prettierrc.json +10 -0
- package/README.md +0 -3
- package/components/es-accordion-list.vue +117 -0
- package/components/es-accordion.vue +7 -0
- package/components/es-badge.vue +20 -0
- package/components/es-breadcrumbs.vue +73 -0
- package/components/es-button.vue +122 -0
- package/components/es-card.vue +64 -0
- package/components/es-carousel.vue +316 -0
- package/components/es-col.vue +27 -0
- package/components/es-collapse.vue +83 -0
- package/components/es-container.vue +14 -0
- package/components/es-cta-banner.vue +95 -0
- package/components/es-cta-card.vue +60 -0
- package/components/es-data-table-simple.vue +73 -0
- package/components/es-data-table.vue +128 -0
- package/components/es-error-page.vue +120 -0
- package/components/es-file-input.vue +334 -0
- package/components/es-file-preview-modal.vue +61 -0
- package/components/es-footer.vue +163 -0
- package/components/es-form-checkbox.vue +40 -0
- package/components/es-form-input.vue +130 -0
- package/components/es-form-msg.vue +81 -0
- package/components/es-form-radio-card.vue +57 -0
- package/components/es-form-radio-cards.vue +78 -0
- package/components/es-form-textarea.vue +124 -0
- package/components/es-form.vue +16 -0
- package/components/es-modal.vue +103 -0
- package/components/es-nav-bar-account-menu.vue +70 -0
- package/components/es-nav-bar-featured-article.vue +60 -0
- package/components/es-nav-bar-link.vue +27 -0
- package/components/es-nav-bar-product-menu.vue +219 -0
- package/components/es-nav-bar-top-level-menu.vue +194 -0
- package/components/es-nav-bar-topic-menu.vue +197 -0
- package/components/es-nav-bar.vue +411 -0
- package/components/es-pagination.vue +67 -0
- package/components/es-popover.vue +113 -0
- package/components/es-progress-circle.vue +81 -0
- package/components/es-progress.vue +36 -0
- package/components/es-radio-button-group.vue +53 -0
- package/components/es-radio-button.vue +57 -0
- package/components/es-rating.vue +120 -0
- package/components/es-reviews-io-card-carousel.vue +279 -0
- package/components/es-row.vue +5 -0
- package/components/es-skeleton-img.vue +38 -0
- package/components/es-skeleton-wrapper.vue +17 -0
- package/components/es-skeleton.vue +97 -0
- package/components/es-slider.vue +218 -0
- package/components/es-spinner.vue +22 -0
- package/components/es-support-card.vue +150 -0
- package/components/es-support.vue +66 -0
- package/components/es-tab.vue +7 -0
- package/components/es-tabs.vue +70 -0
- package/components/es-verification-code.vue +94 -0
- package/components/es-video.vue +60 -0
- package/components/es-view-more.vue +64 -0
- package/components/es-zip-code-form.vue +204 -0
- package/components/icon/arrow-clockwise.vue +51 -0
- package/components/icon/arrow-counter-clockwise.vue +37 -0
- package/components/icon/arrow-cycle.vue +43 -0
- package/components/icon/arrow-left.vue +39 -0
- package/components/icon/arrow-right.vue +39 -0
- package/components/icon/arrow-up.vue +39 -0
- package/components/icon/arrow.vue +68 -0
- package/components/icon/bank.vue +38 -0
- package/components/icon/battery.vue +40 -0
- package/components/icon/bell.vue +42 -0
- package/components/icon/block.vue +46 -0
- package/components/icon/bolt.vue +38 -0
- package/components/icon/business.vue +42 -0
- package/components/icon/calculator.vue +37 -0
- package/components/icon/calendar.vue +46 -0
- package/components/icon/cart.vue +46 -0
- package/components/icon/chat-add.vue +44 -0
- package/components/icon/chat-approve.vue +42 -0
- package/components/icon/chat-bubble.vue +40 -0
- package/components/icon/chat-dots.vue +42 -0
- package/components/icon/check.vue +39 -0
- package/components/icon/chevron-down.vue +39 -0
- package/components/icon/chevron-horizontal.vue +39 -0
- package/components/icon/chevron-left.vue +39 -0
- package/components/icon/chevron-right.vue +39 -0
- package/components/icon/chevron-up.vue +39 -0
- package/components/icon/circle-alert.vue +46 -0
- package/components/icon/circle-check.vue +46 -0
- package/components/icon/circle-chevron-down.vue +47 -0
- package/components/icon/circle-chevron-left.vue +46 -0
- package/components/icon/circle-chevron-right.vue +46 -0
- package/components/icon/circle-chevron-up.vue +46 -0
- package/components/icon/circle-minus.vue +46 -0
- package/components/icon/circle-plus.vue +46 -0
- package/components/icon/circle-x.vue +46 -0
- package/components/icon/circle.vue +42 -0
- package/components/icon/clock.vue +46 -0
- package/components/icon/community-solar.vue +37 -0
- package/components/icon/companies.vue +43 -0
- package/components/icon/contact-us.vue +37 -0
- package/components/icon/copy.vue +45 -0
- package/components/icon/credit-card.vue +37 -0
- package/components/icon/document.vue +45 -0
- package/components/icon/electric-vehicle.vue +37 -0
- package/components/icon/electricity-circle.vue +39 -0
- package/components/icon/envelope.vue +45 -0
- package/components/icon/es-leaf.vue +39 -0
- package/components/icon/eye.vue +41 -0
- package/components/icon/facebook.vue +41 -0
- package/components/icon/file-doc.vue +72 -0
- package/components/icon/file-docx.vue +72 -0
- package/components/icon/file-pdf.vue +72 -0
- package/components/icon/flag.vue +37 -0
- package/components/icon/folder.vue +41 -0
- package/components/icon/gear.vue +37 -0
- package/components/icon/gift.vue +37 -0
- package/components/icon/hamburger.vue +38 -0
- package/components/icon/heart.vue +39 -0
- package/components/icon/heat-pump.vue +41 -0
- package/components/icon/image.vue +45 -0
- package/components/icon/info-blog-post.vue +47 -0
- package/components/icon/info.vue +45 -0
- package/components/icon/instagram.vue +49 -0
- package/components/icon/light-bulb.vue +36 -0
- package/components/icon/link-out.vue +38 -0
- package/components/icon/linkedin.vue +41 -0
- package/components/icon/location.vue +45 -0
- package/components/icon/lock-off.vue +45 -0
- package/components/icon/lock-on.vue +48 -0
- package/components/icon/marketplace.vue +43 -0
- package/components/icon/minus.vue +38 -0
- package/components/icon/money-bag.vue +37 -0
- package/components/icon/money.vue +35 -0
- package/components/icon/open-envelope.vue +45 -0
- package/components/icon/paper.vue +45 -0
- package/components/icon/payback.vue +37 -0
- package/components/icon/pencil.vue +45 -0
- package/components/icon/person-add.vue +41 -0
- package/components/icon/person-check.vue +41 -0
- package/components/icon/person-cross.vue +41 -0
- package/components/icon/person.vue +41 -0
- package/components/icon/phone.vue +41 -0
- package/components/icon/play.vue +41 -0
- package/components/icon/plus.vue +38 -0
- package/components/icon/roof.vue +37 -0
- package/components/icon/rooftop-solar.vue +37 -0
- package/components/icon/saving.vue +37 -0
- package/components/icon/search.vue +43 -0
- package/components/icon/settings-horizontal.vue +57 -0
- package/components/icon/settings-vertical.vue +57 -0
- package/components/icon/shield-with-check.vue +38 -0
- package/components/icon/shield.vue +41 -0
- package/components/icon/shopping-bag.vue +39 -0
- package/components/icon/solar.vue +37 -0
- package/components/icon/sound-down.vue +45 -0
- package/components/icon/sound-off.vue +41 -0
- package/components/icon/sound-on.vue +41 -0
- package/components/icon/sound-up.vue +41 -0
- package/components/icon/star-empty.vue +38 -0
- package/components/icon/star-full.vue +36 -0
- package/components/icon/star-half.vue +55 -0
- package/components/icon/state-ak.vue +53 -0
- package/components/icon/state-al.vue +38 -0
- package/components/icon/state-ar.vue +38 -0
- package/components/icon/state-az.vue +38 -0
- package/components/icon/state-ca.vue +38 -0
- package/components/icon/state-co.vue +38 -0
- package/components/icon/state-ct.vue +38 -0
- package/components/icon/state-dc.vue +52 -0
- package/components/icon/state-de.vue +38 -0
- package/components/icon/state-fl.vue +38 -0
- package/components/icon/state-ga.vue +38 -0
- package/components/icon/state-hi.vue +59 -0
- package/components/icon/state-ia.vue +38 -0
- package/components/icon/state-id.vue +38 -0
- package/components/icon/state-il.vue +38 -0
- package/components/icon/state-in.vue +38 -0
- package/components/icon/state-ks.vue +38 -0
- package/components/icon/state-ky.vue +38 -0
- package/components/icon/state-la.vue +38 -0
- package/components/icon/state-ma.vue +38 -0
- package/components/icon/state-md.vue +39 -0
- package/components/icon/state-me.vue +38 -0
- package/components/icon/state-mi.vue +38 -0
- package/components/icon/state-mn.vue +38 -0
- package/components/icon/state-mo.vue +38 -0
- package/components/icon/state-ms.vue +38 -0
- package/components/icon/state-mt.vue +38 -0
- package/components/icon/state-nc.vue +38 -0
- package/components/icon/state-nd.vue +38 -0
- package/components/icon/state-ne.vue +38 -0
- package/components/icon/state-nh.vue +38 -0
- package/components/icon/state-nj.vue +37 -0
- package/components/icon/state-nm.vue +38 -0
- package/components/icon/state-nv.vue +38 -0
- package/components/icon/state-ny.vue +41 -0
- package/components/icon/state-oh.vue +38 -0
- package/components/icon/state-ok.vue +38 -0
- package/components/icon/state-or.vue +38 -0
- package/components/icon/state-pa.vue +38 -0
- package/components/icon/state-ri.vue +38 -0
- package/components/icon/state-sc.vue +38 -0
- package/components/icon/state-sd.vue +38 -0
- package/components/icon/state-tn.vue +38 -0
- package/components/icon/state-tx.vue +38 -0
- package/components/icon/state-ut.vue +38 -0
- package/components/icon/state-va.vue +38 -0
- package/components/icon/state-vt.vue +38 -0
- package/components/icon/state-wa.vue +38 -0
- package/components/icon/state-wi.vue +38 -0
- package/components/icon/state-wv.vue +38 -0
- package/components/icon/state-wy.vue +38 -0
- package/components/icon/thumbs-up.vue +39 -0
- package/components/icon/trash-can.vue +41 -0
- package/components/icon/tree.vue +37 -0
- package/components/icon/twitter.vue +43 -0
- package/components/icon/upload.vue +41 -0
- package/components/icon/verified.vue +40 -0
- package/components/icon/video-play.vue +41 -0
- package/components/icon/wifi.vue +38 -0
- package/components/icon/x.vue +38 -0
- package/components/icon/youtube.vue +45 -0
- package/composables/forms.ts +124 -0
- package/nuxt.config.ts +83 -1
- package/package.json +47 -13
- package/tsconfig.json +4 -0
- package/types/es-carousel.ts +12 -0
- package/types/index.ts +1 -0
- package/utils/forms.ts +120 -0
- package/utils/get-es-footer-content.ts +90 -0
- package/utils/get-es-nav-bar-account-content.ts +55 -0
- package/utils/get-es-nav-bar-global-content.ts +482 -0
- package/utils/mime-type-finder.ts +48 -0
- package/utils/vuelidate-keys.ts +39 -0
- package/components/TestComponent.vue +0 -5
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import DataTable from 'primevue/datatable';
|
|
3
|
+
import Column from 'primevue/column';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
fields: {
|
|
7
|
+
type: Array,
|
|
8
|
+
default: null,
|
|
9
|
+
},
|
|
10
|
+
items: {
|
|
11
|
+
type: Array,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
stickyFirstCol: {
|
|
15
|
+
type: Boolean,
|
|
16
|
+
default: false,
|
|
17
|
+
},
|
|
18
|
+
stickyHeader: {
|
|
19
|
+
type: [Boolean, String],
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const checkFrozenRow = (index: any) => {
|
|
25
|
+
if (index === 0 && props.stickyFirstCol) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Code is taken from BootStrap Vue to implemnt <b-table> feature that automatically
|
|
32
|
+
// samples the first row to extract field name and "humanize" by converting kebab-case,
|
|
33
|
+
// snake_case, and camelCase to individual words and capitalizes each word.
|
|
34
|
+
// Doc: https://bootstrap-vue.org/docs/components/table
|
|
35
|
+
// Source Code: https://github.com/bootstrap-vue/bootstrap-vue/blob/5173dd19f6f46dc9d125cd7233fb59ccd2ef9296/src/utils/string.js#L30
|
|
36
|
+
const startCase = (str: string) =>
|
|
37
|
+
str
|
|
38
|
+
.replace(/_/g, ' ')
|
|
39
|
+
.replace(/([a-z])([A-Z])/g, (_, $1, $2) => `${$1} ${$2}`)
|
|
40
|
+
.replace(/(\s|^)(\w)/g, (_, $1, $2) => $1 + $2.toUpperCase());
|
|
41
|
+
|
|
42
|
+
const label = (col: any) => {
|
|
43
|
+
if (typeof col === 'string') {
|
|
44
|
+
return startCase(col);
|
|
45
|
+
}
|
|
46
|
+
return col?.label ? col.label : startCase(col?.key);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const columns = computed(() => {
|
|
50
|
+
if (props.fields) {
|
|
51
|
+
return props.fields;
|
|
52
|
+
}
|
|
53
|
+
// @ts-expect-error not sure
|
|
54
|
+
return Object.keys(props.items[0]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const scrollHeight = computed(() => {
|
|
58
|
+
if (props.stickyHeader) {
|
|
59
|
+
if (typeof props.stickyHeader === 'string') {
|
|
60
|
+
return props.stickyHeader;
|
|
61
|
+
}
|
|
62
|
+
return '300px';
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
<template>
|
|
68
|
+
<data-table
|
|
69
|
+
scrollable
|
|
70
|
+
:pt="{
|
|
71
|
+
table: {
|
|
72
|
+
class: 'font-size-75 font-size-md-100',
|
|
73
|
+
style: 'border-collapse: separate; border-spacing: 0;',
|
|
74
|
+
},
|
|
75
|
+
wrapper: {
|
|
76
|
+
style: 'border-radius: inherit',
|
|
77
|
+
},
|
|
78
|
+
thead: {
|
|
79
|
+
style: 'top: 0px; z-index: 2',
|
|
80
|
+
},
|
|
81
|
+
}"
|
|
82
|
+
:value="props.items"
|
|
83
|
+
class="border table__data-table table-spacing table"
|
|
84
|
+
:scroll-height="scrollHeight">
|
|
85
|
+
<!-- @vue-expect-error -->
|
|
86
|
+
<column
|
|
87
|
+
v-for="(col, index) of columns"
|
|
88
|
+
:key="col?.key ? col.key : col"
|
|
89
|
+
:field="col?.key ? col.key : col"
|
|
90
|
+
:header="label(col)"
|
|
91
|
+
:sortable="col?.sortable"
|
|
92
|
+
:frozen="checkFrozenRow(index)"
|
|
93
|
+
align-frozen="left"
|
|
94
|
+
:pt="{
|
|
95
|
+
sortIcon: {
|
|
96
|
+
style: 'display: none',
|
|
97
|
+
},
|
|
98
|
+
headerCell: (options) => ({
|
|
99
|
+
class: [
|
|
100
|
+
{
|
|
101
|
+
sortable: options.props.sortable,
|
|
102
|
+
stickyFirstColHeader: options.props.frozen,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
bodyCell: (options) => ({
|
|
107
|
+
class: [
|
|
108
|
+
{
|
|
109
|
+
stickyFirstCol: options.props.frozen,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
}">
|
|
114
|
+
</column>
|
|
115
|
+
</data-table>
|
|
116
|
+
</template>
|
|
117
|
+
<style scoped lang="scss">
|
|
118
|
+
:deep(.stickyFirstColHeader) {
|
|
119
|
+
position: sticky !important;
|
|
120
|
+
}
|
|
121
|
+
:deep(.stickyFirstCol) {
|
|
122
|
+
position: sticky !important;
|
|
123
|
+
background: inherit;
|
|
124
|
+
}
|
|
125
|
+
:deep(.sortable) {
|
|
126
|
+
cursor: pointer !important;
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import IconContactUs from './icon/contact-us.vue';
|
|
3
|
+
import IconCommunitySolar from './icon/community-solar.vue';
|
|
4
|
+
import IconInfoBlogPost from './icon/info-blog-post.vue';
|
|
5
|
+
import IconMarketplace from './icon/marketplace.vue';
|
|
6
|
+
|
|
7
|
+
const ES_DOMAIN = process.env.ES_DOMAIN || 'https://www.energysage.com';
|
|
8
|
+
const linkItems = [
|
|
9
|
+
{
|
|
10
|
+
icon: IconContactUs,
|
|
11
|
+
link: 'mailto:hello@energysage.com',
|
|
12
|
+
label: 'Contact us',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
icon: IconCommunitySolar,
|
|
16
|
+
link: `${ES_DOMAIN}/solar/`,
|
|
17
|
+
label: 'Learn about solar',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
icon: IconInfoBlogPost,
|
|
21
|
+
link: `${ES_DOMAIN}/blog/`,
|
|
22
|
+
label: 'EnergySage blog',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
icon: IconMarketplace,
|
|
26
|
+
link: `${ES_DOMAIN}/shop/home-solar/`,
|
|
27
|
+
label: 'EnergySage marketplace',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const props = defineProps({
|
|
32
|
+
errorType: {
|
|
33
|
+
type: String,
|
|
34
|
+
required: true,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface ErrorCode {
|
|
39
|
+
shortMessage?: string;
|
|
40
|
+
longMessage: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ErrorCodeMap {
|
|
44
|
+
[key: string]: ErrorCode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const errorCodeToMessages: ErrorCodeMap = {
|
|
48
|
+
403: {
|
|
49
|
+
shortMessage: 'Access denied',
|
|
50
|
+
longMessage:
|
|
51
|
+
"Oops! If you're seeing this message, there's a good chance you have cookies or " +
|
|
52
|
+
'referrers turned off in your browser.',
|
|
53
|
+
},
|
|
54
|
+
404: {
|
|
55
|
+
shortMessage: 'Page not found',
|
|
56
|
+
longMessage: 'Oops! We are terribly sorry, but there is nothing bright to see here.',
|
|
57
|
+
},
|
|
58
|
+
500: {
|
|
59
|
+
shortMessage: 'Internal server error',
|
|
60
|
+
longMessage: 'Oops! We are terribly sorry, but our server is not so bright today. Please try again.',
|
|
61
|
+
},
|
|
62
|
+
503: {
|
|
63
|
+
shortMessage: 'Service unavailable',
|
|
64
|
+
longMessage: 'The page you are requesting is temporarily unavailable.',
|
|
65
|
+
},
|
|
66
|
+
default: {
|
|
67
|
+
longMessage: 'Oops! Something went wrong!',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const messages: ErrorCode = errorCodeToMessages[props.errorType];
|
|
72
|
+
|
|
73
|
+
const errorShortMessage = messages?.shortMessage || props.errorType;
|
|
74
|
+
|
|
75
|
+
const errorLongMessage = messages?.longMessage || errorCodeToMessages.default.longMessage;
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div class="bg-gray-50">
|
|
80
|
+
<es-container class="py-500 py-lg-800">
|
|
81
|
+
<es-row>
|
|
82
|
+
<es-col
|
|
83
|
+
md="7"
|
|
84
|
+
class="my-500 mx-auto text-center">
|
|
85
|
+
<h1 class="post1 text-gray-600">
|
|
86
|
+
{{ errorShortMessage }}
|
|
87
|
+
</h1>
|
|
88
|
+
<p
|
|
89
|
+
id="msgError"
|
|
90
|
+
class="font-size-lg text-gray-600">
|
|
91
|
+
{{ errorLongMessage }}
|
|
92
|
+
</p>
|
|
93
|
+
</es-col>
|
|
94
|
+
</es-row>
|
|
95
|
+
<es-row>
|
|
96
|
+
<es-col
|
|
97
|
+
v-for="(item, index) in linkItems"
|
|
98
|
+
:key="index"
|
|
99
|
+
cols="6"
|
|
100
|
+
md="3">
|
|
101
|
+
<a
|
|
102
|
+
class="align-items-center d-flex flex-column justify-content-center text-center"
|
|
103
|
+
:href="item.link">
|
|
104
|
+
<div
|
|
105
|
+
class="bg-white d-flex justify-content-center mb-50 rounded-circle"
|
|
106
|
+
align-v="center">
|
|
107
|
+
<div class="m-100 text-orange">
|
|
108
|
+
<component
|
|
109
|
+
:is="item.icon"
|
|
110
|
+
height="53px"
|
|
111
|
+
width="53px" />
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<p class="mb-200 mb-md-0">{{ item.label }}</p>
|
|
115
|
+
</a>
|
|
116
|
+
</es-col>
|
|
117
|
+
</es-row>
|
|
118
|
+
</es-container>
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
type UploadInfo = {
|
|
3
|
+
name: string;
|
|
4
|
+
uploadUrl: string;
|
|
5
|
+
additionalFields?: object;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
/**
|
|
10
|
+
* An array of Objects with the following shape:
|
|
11
|
+
* {
|
|
12
|
+
* name: String,
|
|
13
|
+
* uploadUrl: String,
|
|
14
|
+
* additionalFields: Object,
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
uploadUrls: {
|
|
18
|
+
type: Array<UploadInfo>,
|
|
19
|
+
default: () => [],
|
|
20
|
+
},
|
|
21
|
+
/**
|
|
22
|
+
* The file types that are allowed to be uploaded. This is a list of MIME types as strings. If the array is
|
|
23
|
+
* empty, all file types are allowed. A list of acceptable MIME types can be found here in the template column:
|
|
24
|
+
* https://www.iana.org/assignments/media-types/media-types.xhtml
|
|
25
|
+
*/
|
|
26
|
+
fileTypes: {
|
|
27
|
+
type: Array,
|
|
28
|
+
default: () => [],
|
|
29
|
+
required: true,
|
|
30
|
+
validator(fileTypes: any[]) {
|
|
31
|
+
return fileTypes.every((fileType) => mimeTypes.includes(fileType));
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* The maximum file size in MB. This is per file, not the total size of all files.
|
|
36
|
+
*/
|
|
37
|
+
maxFileSizeMb: {
|
|
38
|
+
type: Number,
|
|
39
|
+
default: 25,
|
|
40
|
+
required: false,
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* When true, the component will be shorter vertically. This is achieved by giving the parent div
|
|
44
|
+
* flex-direction: row instead of the default flex-direction: column.
|
|
45
|
+
*/
|
|
46
|
+
collapsed: {
|
|
47
|
+
type: Boolean,
|
|
48
|
+
default: false,
|
|
49
|
+
required: false,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let currentFiles: Array<File> = [];
|
|
54
|
+
let active = false;
|
|
55
|
+
const fileInput = ref(null);
|
|
56
|
+
const emit = defineEmits([
|
|
57
|
+
'fileSizeError',
|
|
58
|
+
'fileTypeError',
|
|
59
|
+
'fileIsAFolderError',
|
|
60
|
+
'fileDataRead',
|
|
61
|
+
'duplicateFileMessage',
|
|
62
|
+
'readyToUpload',
|
|
63
|
+
'uploadFailure',
|
|
64
|
+
'uploadSuccess',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const filterLargeFiles = (files: Array<File>) => {
|
|
68
|
+
// Takes an array of files, and filters out any files that exceed the maxFileSizeMb prop. Emits a
|
|
69
|
+
// fileSizeError event for each file that exceeds the maxFileSizeMb prop. The File API uses bytes, so
|
|
70
|
+
// we have to convert the maxFileSizeMb prop to bytes.
|
|
71
|
+
const maxFileSizeBytes = props.maxFileSizeMb * 1000000;
|
|
72
|
+
return [...files].filter((file) => {
|
|
73
|
+
if (file.size > maxFileSizeBytes) {
|
|
74
|
+
emit('fileSizeError', file.name);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const removeSpaceFromFileNames = (files: Array<File>) => {
|
|
82
|
+
const newFiles = files.map(
|
|
83
|
+
(file) =>
|
|
84
|
+
new File(
|
|
85
|
+
// Return new File object with the modified name (without any space)
|
|
86
|
+
[file],
|
|
87
|
+
file.name.replace(/\s/g, ''),
|
|
88
|
+
{ type: file.type, lastModified: file.lastModified },
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
return newFiles;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
async function verifyMimeType(file: File) {
|
|
95
|
+
// If an empty folder then trigger fileIsAFolderError
|
|
96
|
+
if (file.type === '') {
|
|
97
|
+
emit('fileIsAFolderError');
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const fileReader = new FileReader();
|
|
101
|
+
fileReader.onload = (evt) => {
|
|
102
|
+
// @ts-expect-error not sure
|
|
103
|
+
const uint = new Uint8Array(evt.target.result);
|
|
104
|
+
const bytes: string[] = [];
|
|
105
|
+
uint.forEach((byte) => {
|
|
106
|
+
bytes.push(byte.toString(16));
|
|
107
|
+
});
|
|
108
|
+
const hex = bytes.join('').toUpperCase();
|
|
109
|
+
const mimeType = findMimeType(hex);
|
|
110
|
+
if (!mimeType || (props.fileTypes.length > 0 && !props.fileTypes.includes(mimeType))) {
|
|
111
|
+
emit('fileTypeError', file.name);
|
|
112
|
+
return reject();
|
|
113
|
+
}
|
|
114
|
+
return resolve(file);
|
|
115
|
+
};
|
|
116
|
+
fileReader.onerror = (error) => {
|
|
117
|
+
reject(error);
|
|
118
|
+
};
|
|
119
|
+
const blob = file.slice(0, 4);
|
|
120
|
+
fileReader.readAsArrayBuffer(blob);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function uploadSingleFile(file: File) {
|
|
125
|
+
const uploadInfo = props.uploadUrls.find((uploadUrl) => uploadUrl.name === file.name);
|
|
126
|
+
if (!uploadInfo) {
|
|
127
|
+
emit('uploadFailure', {
|
|
128
|
+
name: file.name,
|
|
129
|
+
message: 'No upload URL found for this file.',
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const form = new FormData();
|
|
134
|
+
if (uploadInfo.additionalFields) {
|
|
135
|
+
Object.entries(uploadInfo.additionalFields).forEach(([key, value]) => {
|
|
136
|
+
form.append(key, value);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
form.append('file', file);
|
|
140
|
+
fetch(uploadInfo.uploadUrl, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
body: form,
|
|
143
|
+
})
|
|
144
|
+
.then((response) => {
|
|
145
|
+
if (response.status >= 200 && response.status < 300) {
|
|
146
|
+
emit('uploadSuccess', file.name);
|
|
147
|
+
} else {
|
|
148
|
+
emit('uploadFailure', {
|
|
149
|
+
name: file.name,
|
|
150
|
+
message: `Received ${response.status} code from server.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
if (error.response) {
|
|
156
|
+
// The request was made and the server responded with a status code
|
|
157
|
+
// that falls out of the range of 2xx
|
|
158
|
+
emit('uploadFailure', {
|
|
159
|
+
name: file.name,
|
|
160
|
+
message: `Received ${error.response.status} code from server.`,
|
|
161
|
+
});
|
|
162
|
+
} else if (error.request) {
|
|
163
|
+
// The request was made but no response was received
|
|
164
|
+
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
|
165
|
+
// http.ClientRequest in node.js
|
|
166
|
+
emit('uploadFailure', { name: file.name, message: 'The server did not respond.' });
|
|
167
|
+
} else {
|
|
168
|
+
// Something happened in setting up the request that triggered an Error
|
|
169
|
+
emit('uploadFailure', {
|
|
170
|
+
name: file.name,
|
|
171
|
+
message: 'There was an error sending your file to the server.',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const readFilesIntoUrl = (files: Array<File>) => {
|
|
178
|
+
files.forEach((file) => {
|
|
179
|
+
const fileReader = new FileReader();
|
|
180
|
+
fileReader.onload = () => {
|
|
181
|
+
emit('fileDataRead', {
|
|
182
|
+
name: file.name,
|
|
183
|
+
type: file.type,
|
|
184
|
+
size: file.size,
|
|
185
|
+
// For non-image files (pdf, docx, etc.)
|
|
186
|
+
data: file.type.includes('application')
|
|
187
|
+
? URL.createObjectURL(new Blob([file], { type: file.type }))
|
|
188
|
+
: fileReader.result,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
fileReader.readAsDataURL(file);
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
async function verifyFiles(files: Array<File>) {
|
|
196
|
+
const correctlySizedFiles = filterLargeFiles(files);
|
|
197
|
+
const correctlyNamedFiles = removeSpaceFromFileNames(correctlySizedFiles);
|
|
198
|
+
|
|
199
|
+
// Make sure the file is the correct mime type
|
|
200
|
+
let newValidFiles = (await Promise.allSettled(
|
|
201
|
+
correctlyNamedFiles.map(async (file) => verifyMimeType(file)),
|
|
202
|
+
)) as unknown as File[];
|
|
203
|
+
// Filter out undefined values and "rejected"
|
|
204
|
+
// @ts-expect-error basically we're filtering out the non-Files here
|
|
205
|
+
newValidFiles = newValidFiles.filter((file) => file && file.status === 'fulfilled').map((file) => file.value);
|
|
206
|
+
|
|
207
|
+
// If the new file with name already exists in the current files
|
|
208
|
+
// Don't upload the new file and display an error
|
|
209
|
+
const duplicateFiles = newValidFiles.filter(({ name }) => currentFiles.some((file) => file.name === name));
|
|
210
|
+
duplicateFiles.forEach((file) => emit('duplicateFileMessage', file.name));
|
|
211
|
+
|
|
212
|
+
// For new files with unused names, add them to files
|
|
213
|
+
newValidFiles = newValidFiles.filter(({ name }) => !currentFiles.some((file) => file.name === name));
|
|
214
|
+
currentFiles = [...currentFiles, ...newValidFiles];
|
|
215
|
+
|
|
216
|
+
if (newValidFiles.length > 0) {
|
|
217
|
+
emit('readyToUpload', newValidFiles);
|
|
218
|
+
readFilesIntoUrl(newValidFiles);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
watch(
|
|
223
|
+
() => props.uploadUrls,
|
|
224
|
+
(newUrls, oldUrls) => {
|
|
225
|
+
// For every file that has an associated upload URL, we start the upload
|
|
226
|
+
newUrls.forEach((newUrlPair) => {
|
|
227
|
+
const oldUrl = oldUrls.find(({ name }) => name === newUrlPair.name);
|
|
228
|
+
const fileToUpload = currentFiles.find((file) => file.name === newUrlPair.name);
|
|
229
|
+
if ((!oldUrl || oldUrl.uploadUrl !== newUrlPair.uploadUrl) && fileToUpload) {
|
|
230
|
+
uploadSingleFile(fileToUpload);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
async function onFileChanged($event: Event) {
|
|
237
|
+
const target = $event.target as HTMLInputElement;
|
|
238
|
+
// The user has selected files from the file picker. We have to filter out any files that exceed the
|
|
239
|
+
// maxFileSizeMb prop.
|
|
240
|
+
if (target && target.files) {
|
|
241
|
+
await verifyFiles([...target.files]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const onDrop = (event: any) => {
|
|
246
|
+
// The user has dropped files onto the component. We have to apply the same logic as if they had
|
|
247
|
+
// selected the files from the file picker which limits the file types to the ones specified in the
|
|
248
|
+
// fileTypes prop.
|
|
249
|
+
active = false;
|
|
250
|
+
|
|
251
|
+
// Use DataTransferItemList interface to access the file(s)
|
|
252
|
+
const dataTransfersAsFiles = [...event.dataTransfer.items]
|
|
253
|
+
.filter((item) => {
|
|
254
|
+
if (item.kind !== 'file') {
|
|
255
|
+
emit('fileTypeError', item.name);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
})
|
|
260
|
+
.map((item) => item.getAsFile());
|
|
261
|
+
|
|
262
|
+
verifyFiles(dataTransfersAsFiles);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const openFilePicker = () => {
|
|
266
|
+
if (fileInput.value) {
|
|
267
|
+
// @ts-expect-error not sure
|
|
268
|
+
fileInput.value.click();
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
</script>
|
|
272
|
+
|
|
273
|
+
<template>
|
|
274
|
+
<div
|
|
275
|
+
role="presentation"
|
|
276
|
+
class="es-file-upload align-items-center rounded-sm d-flex flex-column justify-content-center p-150"
|
|
277
|
+
:class="{ active: active }"
|
|
278
|
+
@dragenter.stop.prevent="active = true"
|
|
279
|
+
@dragover.stop.prevent="active = true"
|
|
280
|
+
@dragleave.stop.prevent="active = false"
|
|
281
|
+
@dragend.stop.prevent="onDrop"
|
|
282
|
+
@drop.stop.prevent="onDrop"
|
|
283
|
+
@click.self="openFilePicker"
|
|
284
|
+
@keydown.enter.prevent="openFilePicker"
|
|
285
|
+
@keydown.space.prevent="openFilePicker">
|
|
286
|
+
<div
|
|
287
|
+
class="d-flex justify-content-center flex-column"
|
|
288
|
+
:class="{ 'flex-md-row': collapsed }">
|
|
289
|
+
<div class="align-self-center">
|
|
290
|
+
<icon-upload
|
|
291
|
+
class="d-none d-md-inline-block"
|
|
292
|
+
:class="{ 'mb-200': collapsed }"
|
|
293
|
+
height="32px"
|
|
294
|
+
width="32px" />
|
|
295
|
+
<icon-upload
|
|
296
|
+
class="d-md-none"
|
|
297
|
+
height="24px"
|
|
298
|
+
width="24px" />
|
|
299
|
+
</div>
|
|
300
|
+
<div
|
|
301
|
+
class="align-self-center text-center"
|
|
302
|
+
:class="{ 'px-100': collapsed }">
|
|
303
|
+
<slot name="cta" />
|
|
304
|
+
</div>
|
|
305
|
+
<div class="align-self-center">
|
|
306
|
+
<es-button
|
|
307
|
+
class="d-none d-md-inline w-100 w-md-auto"
|
|
308
|
+
:class="{ 'mb-200': collapsed }"
|
|
309
|
+
@click="openFilePicker">
|
|
310
|
+
<slot name="buttonText">
|
|
311
|
+
<p class="m-0">Browse files</p>
|
|
312
|
+
</slot>
|
|
313
|
+
</es-button>
|
|
314
|
+
<es-button
|
|
315
|
+
class="d-md-none w-100"
|
|
316
|
+
@click="openFilePicker">
|
|
317
|
+
<slot name="buttonText">
|
|
318
|
+
<p class="m-0">Choose files</p>
|
|
319
|
+
</slot>
|
|
320
|
+
</es-button>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
<div>
|
|
324
|
+
<slot name="helpText" />
|
|
325
|
+
</div>
|
|
326
|
+
<input
|
|
327
|
+
ref="fileInput"
|
|
328
|
+
type="file"
|
|
329
|
+
:accept="fileTypes.join(', ')"
|
|
330
|
+
style="display: none"
|
|
331
|
+
multiple
|
|
332
|
+
@change="onFileChanged($event)" />
|
|
333
|
+
</div>
|
|
334
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps({
|
|
3
|
+
visible: {
|
|
4
|
+
type: Boolean,
|
|
5
|
+
default: false,
|
|
6
|
+
required: false,
|
|
7
|
+
},
|
|
8
|
+
fileName: {
|
|
9
|
+
type: String,
|
|
10
|
+
default: 'test.png',
|
|
11
|
+
required: false,
|
|
12
|
+
},
|
|
13
|
+
fileSource: {
|
|
14
|
+
type: String,
|
|
15
|
+
default: '',
|
|
16
|
+
required: false,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
defineEmits(['hidden']);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<es-modal
|
|
25
|
+
:id="`preview-modal-${fileName}`"
|
|
26
|
+
hide-footer
|
|
27
|
+
:visible="visible"
|
|
28
|
+
size="xl"
|
|
29
|
+
body-class="border-top p-0"
|
|
30
|
+
@hidden="$emit('hidden')">
|
|
31
|
+
<template #modal-title>
|
|
32
|
+
{{ fileName }}
|
|
33
|
+
</template>
|
|
34
|
+
<div class="d-md-none d-block file-preview text-center">
|
|
35
|
+
<nuxt-img
|
|
36
|
+
loading="lazy"
|
|
37
|
+
class="img-fluid file-preview"
|
|
38
|
+
:src="fileSource"
|
|
39
|
+
:alt="fileName" />
|
|
40
|
+
</div>
|
|
41
|
+
<div class="d-none d-md-block">
|
|
42
|
+
<div class="px-500 py-200 bg-gray-200">
|
|
43
|
+
<div class="card bg-white file-preview">
|
|
44
|
+
<nuxt-img
|
|
45
|
+
loading="lazy"
|
|
46
|
+
class="img-fluid file-preview rounded-img"
|
|
47
|
+
:src="fileSource"
|
|
48
|
+
:alt="fileName" />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</es-modal>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style lang="scss" scoped>
|
|
56
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
57
|
+
|
|
58
|
+
.rounded-img {
|
|
59
|
+
border-radius: variables.$card-inner-border-radius !important;
|
|
60
|
+
}
|
|
61
|
+
</style>
|