tantee-nuxt-commons 0.0.169 → 0.0.170

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/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0"
6
6
  },
7
- "version": "0.0.169",
7
+ "version": "0.0.170",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -45,7 +45,6 @@ watch(() => alert?.hasAlert(), (hasAlert) => {
45
45
  elevation="2"
46
46
  theme="dark"
47
47
  variant="flat"
48
- v-bind="currentItem.alertIcon ? { icon: currentItem.alertIcon } : {}"
49
48
  @click:close="renewAlert()"
50
49
  >
51
50
  {{ currentItem.statusCode ? currentItem.statusCode + ' ' : '' }} {{ currentItem.message }} {{ !isEmpty(currentItem.data) ? currentItem.data : '' }}
@@ -73,7 +73,7 @@ const loadFormData = () => {
73
73
  formData.value = cloneDeep(formDataOriginalValue.value)
74
74
  }
75
75
  else {
76
- formData.value = Object.assign({}, cloneDeep(props.initialData))
76
+ formData.value = Object.assign({}, props.initialData)
77
77
  }
78
78
  }
79
79
 
@@ -85,7 +85,7 @@ const loadFormData = () => {
85
85
  formDataOriginalValue.value = cloneDeep(props.formData)
86
86
  }
87
87
  else {
88
- formData.value = Object.assign({}, cloneDeep(props.initialData))
88
+ formData.value = Object.assign({}, props.initialData)
89
89
  }
90
90
  isSavedAndStay.value = false
91
91
  }
@@ -78,7 +78,7 @@ const loadFormData = () => {
78
78
  formDataOriginalValue.value = cloneDeep(props.formData)
79
79
  }
80
80
  else {
81
- formData.value = Object.assign({}, cloneDeep(props.initialData))
81
+ formData.value = Object.assign({}, props.initialData)
82
82
  }
83
83
  }
84
84
 
@@ -1,172 +1,225 @@
1
1
  <script lang="ts" setup>
2
- import {isEqual, uniqWith} from 'lodash-es'
3
- import {ref, watch} from 'vue'
4
- import {VTextField} from 'vuetify/components/VTextField'
5
- import {useAlert} from '../../composables/alert'
2
+ import { castArray } from 'lodash-es'
3
+ import { ref, shallowRef, watch, computed } from 'vue'
4
+ import { VTextField } from 'vuetify/components/VTextField'
5
+ import { useAlert } from '../../composables/alert'
6
+ import { type Base64File } from '../../composables/assetFile'
7
+ import { useAssetFile } from '../../composables/assetFile'
6
8
 
7
9
  const alert = useAlert()
10
+ const { hydrateAssetFile, base64ToFile, fileToBase64, downloadBase64File } = useAssetFile()
8
11
 
9
- interface Base64String {
10
- base64String?: string
11
- fileName: string
12
- originalFileName?: string
13
- id?: number
14
- }
12
+ const fileToBase64WithMaxSize = (file: File) => fileToBase64(file, props.maxSize)
15
13
 
16
14
  interface Props extends /* @vue-ignore */ InstanceType<typeof VTextField['$props']> {
17
15
  accept?: string
18
16
  multiple?: boolean
19
17
  maxSize?: number
20
- modelValue?: Base64String | Base64String[]
18
+ modelValue?: Base64File | Base64File[] | null
21
19
  downloadable?: boolean
20
+ autoHydrate?: boolean
22
21
  }
23
22
 
24
23
  const props = withDefaults(defineProps<Props>(), {
25
24
  accept: '*',
26
25
  multiple: false,
27
26
  maxSize: 5,
28
- downloadable: false
27
+ downloadable: false,
28
+ autoHydrate: false,
29
29
  })
30
30
 
31
31
  const emit = defineEmits<{
32
- (e: 'update:modelValue', value: Base64String | Base64String[]): void
32
+ (e: 'update:modelValue', value: Base64File | Base64File[] | null): void
33
33
  }>()
34
34
 
35
- const allFiles = ref<File[]>([])
36
- const allAssets = ref<Base64String[]>([])
37
- const combinedBase64String = ref<Base64String[] | Base64String>([])
38
- const fileInput = ref()
35
+ /** UI ref */
36
+ const fileInput = ref<HTMLInputElement | null>(null)
39
37
 
40
- function openWindowUpload() {
41
- if (props.multiple || (!allFiles.value?.length && !allAssets.value?.length)) {
42
- fileInput.value?.click()
43
- }
44
- }
38
+ /** Internal sources (always arrays) */
39
+ const assets = ref<Base64File[]>([]) // items with server id (or ones we keep as “assets”)
40
+ const files = shallowRef<File[]>([]) // native File objects picked by the user
45
41
 
46
- function addFiles(files: File | File[]) {
47
- if (Array.isArray(files)) allFiles.value?.push(...files)
48
- else allFiles.value?.push(files)
49
- }
42
+ /** Cache to avoid re-reading the same File repeatedly */
43
+ const fileCache = new WeakMap<File, Promise<Base64File>>()
50
44
 
51
- function removeFileByIndex(i: number | string) {
52
- const index = Number(i)
53
- if (index >= 0 && index < allFiles.value.length) allFiles.value.splice(index, 1)
45
+ /** Re-entrancy guard to break the emit -> props watcher -> syncFromModel loop */
46
+ let internalSync = false
47
+
48
+ /** Build a stable dedupe key */
49
+ function base64FileKey(x: Base64File): string {
50
+ if (x.id != null) return `id:${x.id}`
51
+ const name = x.fileName ?? ''
52
+ const len = x.base64String?.length ?? 0
53
+ return `n:${name}|l:${len}`
54
54
  }
55
55
 
56
- function removeAssetByIndex(i: number | string) {
57
- const index = Number(i)
58
- if (index >= 0 && index < allAssets.value.length) allAssets.value.splice(index, 1)
56
+ /** Dedupe by key (preserves order) */
57
+ function uniqByKey(arr: Base64File[]): Base64File[] {
58
+ const seen = new Set<string>()
59
+ const out: Base64File[] = []
60
+ for (const it of arr) {
61
+ const k = base64FileKey(it)
62
+ if (!seen.has(k)) {
63
+ seen.add(k)
64
+ out.push(it)
65
+ }
66
+ }
67
+ return out
59
68
  }
60
69
 
61
- function fileToBase64(file: File) {
62
- const maxSize = props.maxSize * 1048576
70
+ /** Shallow equal by key sequence */
71
+ function arraysEqualByKey(a: Base64File[], b: Base64File[]): boolean {
72
+ if (a === b) return true
73
+ if (a.length !== b.length) return false
74
+ for (let i = 0; i < a.length; i++) {
75
+ if (base64FileKey(a[i]) !== base64FileKey(b[i])) return false
76
+ }
77
+ return true
78
+ }
63
79
 
64
- return new Promise<Base64String>((resolve, reject) => {
65
- if (file.size > maxSize) reject(`File (${file.name}) size exceeds the ${props.maxSize} MB limit.`)
80
+ /** Normalize incoming modelValue into arrays for internal use */
81
+ async function syncFromModel() {
82
+ const mv = props.modelValue
83
+ if (!mv) {
84
+ assets.value = []
85
+ files.value = []
86
+ return
87
+ }
66
88
 
67
- const reader = new FileReader()
68
- reader.onload = (event) => {
69
- resolve({ fileName: file.name, base64String: event.target?.result as string })
70
- }
71
- reader.onerror = reject
72
- reader.readAsDataURL(file)
73
- })
74
- }
89
+ const asArray = castArray(mv) as Base64File[]
75
90
 
76
- function base64ToFile(base64Data: string, filename: string, defaultContentType: string = "application/octet-stream") {
77
- const matchResult = base64Data.match(/data:([^;]*);base64,(.*)/);
78
- let contentType: string;
79
- let base64Payload: string;
91
+ // Split into “asset with id” vs “inline base64” (no id)
92
+ const incomingAssets = asArray.filter(a => a.id != null)
93
+ const inlineBase64 = asArray.filter(a => a.id == null && a.base64String)
80
94
 
81
- if (matchResult) {
82
- [contentType, base64Payload] = matchResult.slice(1)
83
- } else {
84
- contentType = defaultContentType
85
- base64Payload = base64Data
95
+ // Convert inline base64 to File for consistent UX
96
+ const inlineFiles: File[] = []
97
+ for (const item of inlineBase64) {
98
+ const f = base64ToFile(item.base64String as string, item.fileName)
99
+ if (f) inlineFiles.push(f)
86
100
  }
87
101
 
88
- try {
89
- const binaryStr = atob(base64Payload)
90
- const bytes = new Uint8Array(binaryStr.length)
91
- for (let i = 0; i < binaryStr.length; i++) {
92
- bytes[i] = binaryStr.charCodeAt(i)
102
+ assets.value = incomingAssets
103
+ files.value = inlineFiles
104
+
105
+ // Optionally hydrate assets that need it
106
+ if (props.autoHydrate) {
107
+ const needsHydration = assets.value.filter(a => a.id != null && !a.base64String)
108
+ if (needsHydration.length) {
109
+ await Promise.allSettled(needsHydration.map(hydrateAssetFile))
93
110
  }
94
- return new File([bytes], filename, { type: contentType });
95
- } catch (error) {
96
- console.error("Invalid base64 data", error);
97
- return undefined;
98
111
  }
99
112
  }
100
113
 
101
- function downloadBase64File(base64Data: string, filename: string): void {
102
- const file = base64ToFile(base64Data,filename)
103
-
104
- if (file) {
105
- const link = document.createElement("a");
106
- link.href = URL.createObjectURL(file);
107
- link.download = filename;
114
+ /** Open chooser */
115
+ function openWindowUpload() {
116
+ if (props.multiple || (assets.value.length === 0 && files.value.length === 0)) {
117
+ fileInput.value?.click()
118
+ }
119
+ }
108
120
 
109
- // Append the link to the body temporarily and trigger the download
110
- document.body.appendChild(link);
111
- link.click();
121
+ /** Add files from input (single or multiple) */
122
+ function addFiles(payload: File | File[]) {
123
+ if (Array.isArray(payload)) files.value = [...files.value, ...payload]
124
+ else files.value = [...files.value, payload]
125
+ }
112
126
 
113
- // Cleanup
114
- document.body.removeChild(link);
115
- URL.revokeObjectURL(link.href);
127
+ /** Remove chips */
128
+ function removeFileByIndex(i: number | string) {
129
+ const idx = Number(i)
130
+ if (idx >= 0 && idx < files.value.length) {
131
+ const copy = files.value.slice()
132
+ copy.splice(idx, 1)
133
+ files.value = copy
134
+ }
135
+ }
136
+ function removeAssetByIndex(i: number | string) {
137
+ const idx = Number(i)
138
+ if (idx >= 0 && idx < assets.value.length) {
139
+ const copy = assets.value.slice()
140
+ copy.splice(idx, 1)
141
+ assets.value = copy
116
142
  }
117
143
  }
118
144
 
145
+ /** Convert current files → Base64File[] (cached) */
146
+ async function filesToBase64Files(list: File[]): Promise<Base64File[]> {
147
+ const tasks = list.map(f => {
148
+ let p = fileCache.get(f)
149
+ if (!p) {
150
+ p = fileToBase64WithMaxSize(f)
151
+ fileCache.set(f, p)
152
+ }
153
+ return p
154
+ })
155
+ return Promise.all(tasks)
156
+ }
119
157
 
158
+ /** Combined result (array form) */
159
+ const combinedArray = ref<Base64File[]>([])
160
+
161
+ /** Dirty flag for <v-text-field> */
162
+ const isDirty = computed(() =>
163
+ props.multiple ? combinedArray.value.length > 0 : combinedArray.value[0] != null
164
+ )
165
+
166
+ /** Sync from props.modelValue (guarded) */
120
167
  watch(
121
168
  () => props.modelValue,
122
169
  () => {
123
- if (Array.isArray(props.modelValue)) {
124
- allAssets.value = props.modelValue.filter((item) => item.id !== undefined)
125
- allFiles.value = props.modelValue
126
- .filter((item) => item.id === undefined && item.base64String !== undefined)
127
- .map((base64) => base64ToFile(base64.base64String as string, base64.fileName))
128
- .filter((item) => item !== undefined) as File[]
129
- } else if (props.modelValue) {
130
- allAssets.value = props.modelValue.id !== undefined ? [props.modelValue] : []
131
- allFiles.value =
132
- props.modelValue.id === undefined && props.modelValue.base64String !== undefined
133
- ? [base64ToFile(props.modelValue.base64String, props.modelValue.fileName)].filter(
134
- (item) => item !== undefined
135
- ) as File[]
136
- : []
137
- } else {
138
- allAssets.value = []
139
- allFiles.value = []
140
- }
170
+ if (internalSync) return
171
+ void syncFromModel()
141
172
  },
142
173
  { deep: true, immediate: true }
143
174
  )
144
175
 
145
- watch([allAssets, allFiles], () => {
146
- if (allFiles.value.length) {
147
- const base64Promises = allFiles.value.map(fileToBase64)
148
- Promise.all(base64Promises)
149
- .then((base64Strings) => {
150
- combinedBase64String.value = props.multiple
151
- ? [...allAssets.value, ...base64Strings]
152
- : base64Strings[0] || allAssets.value[0] || null
153
- })
154
- .catch((error) => {
155
- alert?.addAlert({ message: error, alertType: 'error' })
156
- allFiles.value = []
157
- })
158
- } else {
159
- combinedBase64String.value = props.multiple
160
- ? [...allAssets.value]
161
- : allAssets.value[0] || null
162
- }
163
- }, { deep: true, immediate: true })
176
+ /** Rebuild combined and emit (guarded, deduped, only-on-change) */
177
+ watch([assets, files], async () => {
178
+ try {
179
+ const base64FromFiles = await filesToBase64Files(files.value)
180
+
181
+ const mergedArray = props.multiple
182
+ ? [...assets.value, ...base64FromFiles]
183
+ : (assets.value[0] ?? base64FromFiles[0] ?? null)
184
+ ? [assets.value[0] ?? base64FromFiles[0]]
185
+ : []
186
+
187
+ const nextCombined = props.multiple ? uniqByKey(mergedArray) : mergedArray
188
+
189
+ if (!arraysEqualByKey(combinedArray.value, nextCombined)) {
190
+ combinedArray.value = nextCombined
191
+
192
+ internalSync = true
193
+ try {
194
+ if (props.multiple) {
195
+ emit('update:modelValue', combinedArray.value)
196
+ } else {
197
+ emit('update:modelValue', combinedArray.value[0] ?? null)
198
+ }
199
+ } finally {
200
+ // let Vue flush the emit before re-enabling the external sync
201
+ queueMicrotask(() => { internalSync = false })
202
+ }
203
+ }
204
+ } catch (error: any) {
205
+ alert?.addAlert({ message: String(error), alertType: 'error' })
206
+ files.value = []
207
+ const fallback = props.multiple
208
+ ? [...assets.value]
209
+ : (assets.value[0] ? [assets.value[0]] : [])
164
210
 
165
- watch(combinedBase64String, (newValue, oldValue) => {
166
- if (!isEqual(newValue, oldValue)) {
167
- emit('update:modelValue', props.multiple ? uniqWith(newValue as Base64String[], isEqual) : newValue)
211
+ if (!arraysEqualByKey(combinedArray.value, fallback)) {
212
+ combinedArray.value = fallback
213
+ internalSync = true
214
+ try {
215
+ if (props.multiple) emit('update:modelValue', combinedArray.value)
216
+ else emit('update:modelValue', combinedArray.value[0] ?? null)
217
+ } finally {
218
+ queueMicrotask(() => { internalSync = false })
219
+ }
220
+ }
168
221
  }
169
- }, { deep: true })
222
+ }, { deep: true, immediate: true })
170
223
  </script>
171
224
 
172
225
  <template>
@@ -174,28 +227,36 @@ watch(combinedBase64String, (newValue, oldValue) => {
174
227
  v-bind="$attrs"
175
228
  label="Upload files"
176
229
  readonly
177
- :dirty="Array.isArray(combinedBase64String) ? combinedBase64String.length > 0 : !!combinedBase64String"
178
- v-on="Array.isArray(combinedBase64String) && combinedBase64String.length > 0 ? {} : { click: openWindowUpload }"
230
+ :dirty="isDirty"
231
+ v-on="isDirty && !props.multiple ? {} : { click: openWindowUpload }"
179
232
  >
180
233
  <template #default>
234
+ <!-- Server/asset items -->
181
235
  <v-chip
182
- v-for="(asset, index) in allAssets"
183
- :key="asset.fileName"
236
+ v-for="(asset, index) in assets"
237
+ :key="`${asset.id ?? asset.fileName}-${index}`"
184
238
  color="green"
185
239
  variant="flat"
186
240
  closable
187
241
  @click:close="removeAssetByIndex(index)"
188
242
  >
189
243
  {{ asset.originalFileName || asset.fileName }}
190
- <template #append v-if="downloadable">
244
+ <template #append v-if="props.downloadable">
191
245
  <slot name="download" :item="asset">
192
- <v-icon @click="downloadBase64File(asset.base64String || '',asset.originalFileName || asset.fileName)" v-if="asset.base64String">mdi mdi-download</v-icon>
246
+ <v-icon
247
+ v-if="asset.base64String"
248
+ @click.stop="downloadBase64File(asset.base64String, asset.originalFileName || asset.fileName)"
249
+ >
250
+ mdi mdi-download
251
+ </v-icon>
193
252
  </slot>
194
253
  </template>
195
254
  </v-chip>
255
+
256
+ <!-- Local uploaded files -->
196
257
  <v-chip
197
- v-for="(file, index) in allFiles"
198
- :key="file.name"
258
+ v-for="(file, index) in files"
259
+ :key="`${file.name}-${file.size}-${index}`"
199
260
  color="primary"
200
261
  variant="flat"
201
262
  closable
@@ -205,12 +266,14 @@ watch(combinedBase64String, (newValue, oldValue) => {
205
266
  </v-chip>
206
267
  </template>
207
268
 
208
- <template v-if="props.multiple && Array.isArray(combinedBase64String) && combinedBase64String.length > 0" #append-inner>
269
+ <!-- Add more button for multi mode -->
270
+ <template v-if="props.multiple && combinedArray.length > 0" #append-inner>
209
271
  <VBtn variant="text" :icon="true" @click="openWindowUpload">
210
272
  <v-icon>mdi mdi-plus</v-icon>
211
273
  </VBtn>
212
274
  </template>
213
275
  </v-text-field>
276
+
214
277
  <v-file-input
215
278
  ref="fileInput"
216
279
  :accept="props.accept"
@@ -218,4 +281,4 @@ watch(combinedBase64String, (newValue, oldValue) => {
218
281
  style="display: none"
219
282
  @update:model-value="addFiles"
220
283
  />
221
- </template>
284
+ </template>
@@ -497,6 +497,8 @@ defineExpose({
497
497
  show-first-last-page
498
498
  @first="footerProps.setPage(1)"
499
499
  @last="footerProps.setPage(footerProps.pageCount)"
500
+ @next="footerProps.nextPage"
501
+ @prev="footerProps.prevPage"
500
502
  @update:model-value="footerProps.setPage"
501
503
  />
502
504
  </v-row>
@@ -34,7 +34,6 @@ interface Props {
34
34
  parentTemplates?: string|string[]
35
35
  dirtyClass?: string
36
36
  dirtyOnCreate?: boolean
37
- sanitizeDelay?: number
38
37
  }
39
38
 
40
39
  const props = withDefaults(defineProps<Props>(), {
@@ -44,8 +43,7 @@ const props = withDefaults(defineProps<Props>(), {
44
43
  decoration: () => { return {} },
45
44
  parentTemplates: (): string[] => [],
46
45
  dirtyClass: "form-data-dirty",
47
- dirtyOnCreate: false,
48
- sanitizeDelay: 2000,
46
+ dirtyOnCreate: false
49
47
  })
50
48
 
51
49
  const emit = defineEmits(['update:modelValue'])
@@ -89,7 +87,7 @@ function isBlankString(v: unknown): v is string {
89
87
  return isString(v) && v.trim().length === 0
90
88
  }
91
89
 
92
- const sanitizeBlankStrings = debounce(sanitizeBlankStringsRaw, props.sanitizeDelay)
90
+ const sanitizeBlankStrings = debounce(sanitizeBlankStringsRaw, 500)
93
91
 
94
92
  function sanitizeBlankStringsRaw(val: any, original?: any): void {
95
93
  if (!original && props.originalData) {
@@ -127,71 +125,6 @@ function sanitizeBlankStringsRaw(val: any, original?: any): void {
127
125
  }
128
126
  }
129
127
 
130
- function autoSanitizedDisplay(item: any, separator: string = ","): string | undefined {
131
- const isEmptyScalar = (v: any) =>
132
- v === null ||
133
- v === undefined ||
134
- (typeof v === "string" && v.trim() === "") ||
135
- (typeof v === "number" && Number.isNaN(v));
136
-
137
- const toStr = (v: any): string | undefined => {
138
- if (isEmptyScalar(v)) return undefined;
139
- if (typeof v === "string") return v.trim();
140
- if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
141
- if (typeof v === "symbol") return v.description ?? String(v);
142
- if (typeof v === "function") return v.name ? `[Function ${v.name}]` : "[Function]";
143
- return undefined;
144
- };
145
-
146
- // 1) empty -> undefined
147
- if (isEmptyScalar(item)) return undefined;
148
-
149
- // 2) array -> recurse + join
150
- if (Array.isArray(item)) {
151
- const parts = item
152
- .map((x) => autoSanitizedDisplay(x, separator))
153
- .filter((s): s is string => typeof s === "string" && s.trim() !== "");
154
- return parts.length ? parts.join(separator) : undefined;
155
- }
156
-
157
- // simple scalars
158
- const scalar = toStr(item);
159
- if (scalar !== undefined) return scalar;
160
-
161
- // 3) object
162
- if (typeof item === "object") {
163
- // 3.1 label
164
- if ("label" in item) {
165
- const v = autoSanitizedDisplay((item as any).label, separator) ?? toStr((item as any).label);
166
- if (v !== undefined) return v;
167
- }
168
- // 3.2 value
169
- if ("value" in item) {
170
- const v = autoSanitizedDisplay((item as any).value, separator) ?? toStr((item as any).value);
171
- if (v !== undefined) return v;
172
- }
173
-
174
- // 3.3 stringify attributes as key:value (recurse values)
175
- const entries = Object.entries(item as Record<string, any>)
176
- .map(([k, v]) => {
177
- const rendered = autoSanitizedDisplay(v, separator) ?? toStr(v);
178
- if (rendered === undefined || rendered.trim() === "") return undefined;
179
- return `${k}:${rendered}`;
180
- })
181
- .filter((x): x is string => typeof x === "string" && x.trim() !== "");
182
-
183
- return entries.length ? entries.join(separator) : undefined;
184
- }
185
-
186
- // fallback
187
- try {
188
- const s = String(item);
189
- return s.trim() ? s : undefined;
190
- } catch {
191
- return undefined;
192
- }
193
- }
194
-
195
128
  watch(formData, (newValue) => {
196
129
  sanitizeBlankStrings(newValue)
197
130
  emit('update:modelValue', newValue)
@@ -286,7 +219,6 @@ function buildFormComponent() {
286
219
  resetValidate: () => formPadTemplate.value.resetValidate(),
287
220
  isValid,
288
221
  ...templateScriptFunction.value(props, ctx, ...Object.values(vueFunctions)),
289
- autoSanitizedDisplay
290
222
  }
291
223
  },
292
224
  template: componentTemplate,
@@ -347,7 +279,6 @@ defineExpose({
347
279
  :disabled="disabled"
348
280
  :readonly="readonly"
349
281
  :class="$attrs.class"
350
- autocomplete="off"
351
282
  >
352
283
  <template #default="formProvided">
353
284
  <slot
@@ -386,5 +317,5 @@ defineExpose({
386
317
  />
387
318
  </template>
388
319
  <style>
389
- .form-data-dirty,.form-data-dirty:not(.v-input--error) *{color:color-mix(in srgb,currentColor 70%,rgb(var(--v-theme-primary)))!important;text-shadow:0 0 .02em currentColor}
320
+ .form-data-dirty:not(.v-input--error) :not(.v-chip):not(.v-chip *){color:color-mix(in srgb,currentColor 70%,rgb(var(--v-theme-primary)))!important;text-shadow:0 0 .02em currentColor}
390
321
  </style>