@deplois/template 0.0.0-stage → 0.0.2
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/README.md +129 -2
- package/app/app.config.ts +52 -0
- package/app/components/action/button.vue +35 -0
- package/app/components/content/badge.vue +9 -0
- package/app/components/content/card.vue +9 -0
- package/app/components/content/chart.vue +94 -0
- package/app/components/content/formatted-text.vue +127 -0
- package/app/components/content/table.vue +13 -0
- package/app/components/content/timeline.vue +9 -0
- package/app/components/feedback/alert.vue +9 -0
- package/app/components/feedback/loader.vue +53 -0
- package/app/components/feedback/loading-view.vue +21 -0
- package/app/components/feedback/modal.vue +93 -0
- package/app/components/feedback/toast.vue +79 -0
- package/app/components/feedback/tooltip.vue +9 -0
- package/app/components/file/list.vue +36 -0
- package/app/components/file/upload.vue +114 -0
- package/app/components/form/calendar.vue +10 -0
- package/app/components/form/check-box.vue +64 -0
- package/app/components/form/editor.vue +124 -0
- package/app/components/form/input-error.vue +59 -0
- package/app/components/form/input.vue +43 -0
- package/app/components/form/select.vue +10 -0
- package/app/components/form/terms.vue +54 -0
- package/app/components/form/toggle.vue +29 -0
- package/app/components/navigation/breadcrumb.vue +10 -0
- package/app/components/navigation/tabs.vue +9 -0
- package/app/components/page/dashboard.vue +84 -0
- package/app/components/page/error.vue +45 -0
- package/app/components/page/frame.vue +53 -0
- package/app/error.vue +10 -0
- package/app/languages/en-us.ts +87 -0
- package/app/languages/index.ts +15 -0
- package/app/languages/ja-jp.ts +87 -0
- package/app/languages/ko-kr.ts +87 -0
- package/app/plugins/modal.plugin.ts +103 -0
- package/app/plugins/module.ts +49 -0
- package/app/plugins/toast.plugin.ts +44 -0
- package/app/plugins/variables.plugin.ts +28 -0
- package/app/types/file.ts +8 -0
- package/assets/css/colors.css +61 -0
- package/assets/css/main.css +164 -0
- package/nuxt.config.ts +19 -0
- package/package.json +59 -4
- package/public/_deplois/font/LICENSE +104 -0
- package/public/_deplois/font/PretendardVariable.woff2 +0 -0
- package/public/_deplois/logo.svg +3 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
UModal(
|
|
3
|
+
v-if='modal'
|
|
4
|
+
:key='modal.srl'
|
|
5
|
+
:open='!!activeModal'
|
|
6
|
+
:title='modal.title'
|
|
7
|
+
:description='modal.content'
|
|
8
|
+
:dismissible='!modal.isForced && !modal.isSubmitting'
|
|
9
|
+
:close='!modal.isForced && !modal.isSubmitting'
|
|
10
|
+
:ui="{ content: 'max-w-lg rounded-md', description: 'whitespace-pre-wrap', footer: 'justify-end gap-2', ...props.ui }"
|
|
11
|
+
@update:open='close'
|
|
12
|
+
@after:leave='modal = activeModal'
|
|
13
|
+
)
|
|
14
|
+
template(v-if='$slots.header' #header)
|
|
15
|
+
slot(name='header' :modal='modal' :close='() => $modal.remove(modal.srl)')
|
|
16
|
+
template(#body)
|
|
17
|
+
form.space-y-4(:id='`modal-form-${modal.srl}`' @submit.prevent='$modal.confirm(modal.srl)')
|
|
18
|
+
slot(:modal='modal' :confirm='() => $modal.confirm(modal.srl)')
|
|
19
|
+
template(v-for='input in modal.forms' :key='input.name')
|
|
20
|
+
slot(name='field' :field='input' :modal='modal')
|
|
21
|
+
UFormField(:label='input.title' :name='input.name')
|
|
22
|
+
Input.w-full(
|
|
23
|
+
v-if="input.type === 'input'"
|
|
24
|
+
:model-value="typeof input.value === 'string' ? input.value : ''"
|
|
25
|
+
:placeholder='input.placeholder'
|
|
26
|
+
:type="input.name.includes('password') ? 'password' : 'text'"
|
|
27
|
+
:disabled='modal.isSubmitting'
|
|
28
|
+
@update:model-value='input.value = String($event)'
|
|
29
|
+
)
|
|
30
|
+
USelect.w-full(
|
|
31
|
+
v-else-if="input.type === 'select'"
|
|
32
|
+
:model-value="typeof input.value === 'string' ? input.value : ''"
|
|
33
|
+
:items='input.options'
|
|
34
|
+
label-key='name'
|
|
35
|
+
value-key='value'
|
|
36
|
+
:placeholder='input.placeholder'
|
|
37
|
+
:disabled='modal.isSubmitting'
|
|
38
|
+
@update:model-value='input.value = $event'
|
|
39
|
+
)
|
|
40
|
+
Toggle(
|
|
41
|
+
v-else-if="input.type === 'toggle'"
|
|
42
|
+
:model-value='input.value === true'
|
|
43
|
+
:disabled='modal.isSubmitting'
|
|
44
|
+
@update:model-value='input.value = $event'
|
|
45
|
+
)
|
|
46
|
+
template(v-else-if="input.type === 'file'")
|
|
47
|
+
FileUpload(:disabled='modal.isSubmitting' @upload='upload(input, $event)')
|
|
48
|
+
FileList.mt-2(v-if='Array.isArray(input.value)' :files='input.value' :disabled='modal.isSubmitting' @remove='input.value.splice($event, 1)')
|
|
49
|
+
p.text-sm.text-error(v-if='modal.error' role='alert') {{ modal.error }}
|
|
50
|
+
template(#footer)
|
|
51
|
+
slot(name='footer' :modal='modal' :confirm='() => $modal.confirm(modal.srl)' :close='() => $modal.remove(modal.srl)')
|
|
52
|
+
Button(
|
|
53
|
+
v-if="!modal.isForced && modal.type === 'form'"
|
|
54
|
+
:auto-loading='false'
|
|
55
|
+
:class="props.ui?.cancel ?? 'text-muted hover:bg-muted hover:text-default'"
|
|
56
|
+
:disabled='modal.isSubmitting'
|
|
57
|
+
@click='$modal.remove(modal.srl)'
|
|
58
|
+
) {{ modal.buttonText?.cancel || $languages.items[$languages.value].components.common.cancel }}
|
|
59
|
+
Button(
|
|
60
|
+
:auto-loading='false'
|
|
61
|
+
:class="props.ui?.confirm ?? 'bg-primary text-white hover:bg-primary-700'"
|
|
62
|
+
type='submit'
|
|
63
|
+
:form='`modal-form-${modal.srl}`'
|
|
64
|
+
:loading='modal.isSubmitting'
|
|
65
|
+
:disabled='modal.isSubmitting'
|
|
66
|
+
) {{ modal.type === 'alert' ? (modal.buttonText?.cancel || $languages.items[$languages.value].components.common.close) : (modal.buttonText?.continue || $languages.items[$languages.value].components.common.submit) }}
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<script setup lang='ts'>
|
|
70
|
+
import type { ModalFile, ModalForm } from '../../plugins/modal.plugin'
|
|
71
|
+
|
|
72
|
+
const props = defineProps<{ ui?: Partial<Record<'content' | 'header' | 'body' | 'footer' | 'title' | 'description' | 'close' | 'overlay' | 'confirm' | 'cancel', string>> }>()
|
|
73
|
+
const { $languages, $modal } = useNuxtApp()
|
|
74
|
+
const activeModal = computed(() => $modal.modals.filter(item => item.isActive).at(-1))
|
|
75
|
+
const modal = shallowRef(activeModal.value)
|
|
76
|
+
|
|
77
|
+
watch(activeModal, item => {
|
|
78
|
+
if(item) modal.value = item
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function close (open: boolean) {
|
|
82
|
+
if(!open && modal.value) $modal.remove(modal.value.srl)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function upload (form: ModalForm, file: ModalFile) {
|
|
86
|
+
if(modal.value?.isSubmitting) return
|
|
87
|
+
if(!Array.isArray(form.value)) form.value = []
|
|
88
|
+
form.value.push(file)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onBeforeUnmount(() => $modal.clear())
|
|
92
|
+
</script>
|
|
93
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
div.fixed.z-50.flex.flex-col.justify-end.overflow-y-auto.p-1(:class="props.ui?.root ?? 'bottom-3 left-3 right-3 max-h-[calc(100svh-1.5rem)] md:left-auto md:right-5 md:w-96'" aria-live='polite' aria-relevant='additions')
|
|
3
|
+
TransitionGroup.relative.flex.flex-col.gap-2(name='toast' tag='div')
|
|
4
|
+
div.flex.w-full.items-start.gap-3.rounded-md.p-4.shadow-md(
|
|
5
|
+
v-for='(toast, index) in $toast.toasts'
|
|
6
|
+
:key='toast.srl'
|
|
7
|
+
:class="(typeof props.ui?.item == 'string' ? props.ui.item : props.ui?.item?.[toast.type]) ?? ['border bg-default text-default shadow-black/10 dark:shadow-black/30', borders[toast.type]]"
|
|
8
|
+
:style='{ zIndex: index + 1 }'
|
|
9
|
+
:role="toast.type == 'danger' ? 'alert' : 'status'"
|
|
10
|
+
)
|
|
11
|
+
slot(:toast='toast' :close='() => $toast.remove(toast.srl)')
|
|
12
|
+
slot(name='icon' :toast='toast')
|
|
13
|
+
Icon.shrink-0(:name='icons[toast.type]' :class="props.ui?.icon ?? 'mt-0.5 size-5 text-muted'")
|
|
14
|
+
slot(name='message' :toast='toast')
|
|
15
|
+
p.min-w-0.flex-1.whitespace-pre-wrap.break-words.text-sm.leading-5(:class='props.ui?.message') {{ toast.message }}
|
|
16
|
+
slot(name='close' :toast='toast' :close='() => $toast.remove(toast.srl)')
|
|
17
|
+
button.flex.size-6.shrink-0.cursor-pointer.items-center.justify-center.rounded-md.transition-colors(type='button' :aria-label='$languages.items[$languages.value].components.toast.dismiss' :class="props.ui?.close ?? 'text-muted hover:bg-muted hover:text-default'" @click='$toast.remove(toast.srl)')
|
|
18
|
+
Icon.size-4(name='i-lucide-x')
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang='ts'>
|
|
22
|
+
import type { ToastType } from '../../plugins/toast.plugin'
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
ui?: Partial<Record<'root' | 'icon' | 'message' | 'close', string>> & {
|
|
26
|
+
item?: string | Partial<Record<ToastType, string>>
|
|
27
|
+
}
|
|
28
|
+
}>()
|
|
29
|
+
const { $languages, $toast } = useNuxtApp()
|
|
30
|
+
|
|
31
|
+
const borders: Record<ToastType, string> = {
|
|
32
|
+
info: 'border-info',
|
|
33
|
+
success: 'border-success',
|
|
34
|
+
warning: 'border-warning',
|
|
35
|
+
danger: 'border-error'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const icons: Record<ToastType, string> = {
|
|
39
|
+
info: 'i-lucide-info',
|
|
40
|
+
success: 'i-lucide-circle-check',
|
|
41
|
+
warning: 'i-lucide-triangle-alert',
|
|
42
|
+
danger: 'i-lucide-circle-x'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onBeforeUnmount(() => $toast.clear())
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.toast-enter-active,
|
|
50
|
+
.toast-leave-active,
|
|
51
|
+
.toast-move {
|
|
52
|
+
transition: opacity 180ms ease, transform 300ms cubic-bezier(.22, 1, .36, 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.toast-enter-from {
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(1rem) scale(.98);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.toast-leave-to {
|
|
61
|
+
opacity: 0;
|
|
62
|
+
transform: translateY(.5rem) scale(.98);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.toast-leave-active {
|
|
66
|
+
position: absolute;
|
|
67
|
+
right: 0;
|
|
68
|
+
left: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
.toast-enter-active,
|
|
73
|
+
.toast-leave-active,
|
|
74
|
+
.toast-move {
|
|
75
|
+
transition: none;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
UTooltip(:delay-duration='200' v-bind='$attrs')
|
|
3
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
4
|
+
slot(:name='name' v-bind='scope || {}')
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
defineOptions({ inheritAttrs: false })
|
|
9
|
+
</script>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
div.grid.w-full.gap-2
|
|
3
|
+
slot(v-if='!props.files.length' name='empty')
|
|
4
|
+
div.flex.min-w-0.items-center.gap-3.rounded-md.p-3(v-for='(file, index) in props.files' :key='file.id ?? index' :class="props.ui?.item ?? 'border border-accented bg-default'")
|
|
5
|
+
slot(:file='file' :index='index' :remove='() => remove(index)')
|
|
6
|
+
slot(name='preview' :file='file' :index='index')
|
|
7
|
+
div.flex.size-10.shrink-0.items-center.justify-center.overflow-hidden.rounded-md(:class="props.ui?.preview ?? 'bg-muted text-muted'")
|
|
8
|
+
img.size-full.object-cover(v-if="file.type.startsWith('image/') && (file.data || file.url)" :src='file.data || file.url' :alt='file.name')
|
|
9
|
+
Icon.size-5(v-else name='i-lucide-file-text')
|
|
10
|
+
div.min-w-0.flex-1
|
|
11
|
+
slot(name='name' :file='file' :index='index')
|
|
12
|
+
p.truncate.text-sm.font-medium(:class='props.ui?.name') {{ file.name }}
|
|
13
|
+
slot(name='description' :file='file' :index='index')
|
|
14
|
+
p.mt-1.text-xs(:class="props.ui?.description ?? 'text-muted'") {{ typeof file.size == 'number' ? `${(file.size / 1024).toLocaleString(undefined, { maximumFractionDigits: 1 })} KB` : file.size }}
|
|
15
|
+
slot(name='actions' :file='file' :index='index' :remove='() => remove(index)')
|
|
16
|
+
button.flex.size-8.shrink-0.cursor-pointer.items-center.justify-center.rounded-md.transition-colors(v-if='props.removable' type='button' :disabled='props.disabled' :aria-label="$languages.items[$languages.value].components.file.remove.replace('${name}', file.name)" class='disabled:cursor-not-allowed disabled:opacity-50' :class="props.ui?.remove ?? 'text-muted hover:bg-muted hover:text-default'" @click='remove(index)')
|
|
17
|
+
Icon.size-4(name='i-lucide-x')
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang='ts'>
|
|
21
|
+
import type { FileItem } from '../../types/file'
|
|
22
|
+
|
|
23
|
+
const { $languages } = useNuxtApp()
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
files: Array<FileItem>,
|
|
27
|
+
removable?: boolean,
|
|
28
|
+
disabled?: boolean,
|
|
29
|
+
ui?: Partial<Record<'item' | 'preview' | 'name' | 'description' | 'remove', string>>
|
|
30
|
+
}>(), { removable: true, disabled: false })
|
|
31
|
+
const emit = defineEmits<{ remove: [index: number] }>()
|
|
32
|
+
|
|
33
|
+
function remove (index: number) {
|
|
34
|
+
if(!props.disabled && props.removable) emit('remove', index)
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
div.w-full
|
|
3
|
+
div.relative.flex.w-full.flex-col.items-center.justify-center.gap-2.rounded-md.border.border-dashed.p-4.text-center.transition-colors(
|
|
4
|
+
:class="[props.ui?.dropzone ?? 'min-h-32 border-accented bg-default', { 'cursor-not-allowed opacity-50': props.disabled, 'ring-2 ring-current': dragCount > 0 && !props.disabled }]"
|
|
5
|
+
@dragenter.prevent='dragCount++'
|
|
6
|
+
@dragover.prevent
|
|
7
|
+
@dragleave.prevent='dragCount = Math.max(0, dragCount - 1)'
|
|
8
|
+
@drop.prevent='drop'
|
|
9
|
+
)
|
|
10
|
+
input.sr-only(:id='inputId' ref='fileInput' type='file' :accept='props.accept' :multiple='props.multiple' :disabled='props.disabled' :aria-label='(props.label ?? $languages.items[$languages.value].components.file.select)' tabindex='-1' @change='select')
|
|
11
|
+
slot(:open='open' :disabled='props.disabled' :is-reading='isReading' :is-dragging='dragCount > 0')
|
|
12
|
+
Icon.size-6(:name="isReading ? 'i-lucide-loader-circle' : 'i-lucide-upload'" :class="[props.ui?.icon ?? 'text-muted', { 'animate-spin': isReading }]")
|
|
13
|
+
button.cursor-pointer.text-sm.font-medium(type='button' :disabled='props.disabled' class='disabled:cursor-not-allowed' :class="props.ui?.button ?? 'text-default hover:text-muted'" @click='open') {{ (props.label ?? $languages.items[$languages.value].components.file.select) }}
|
|
14
|
+
p.text-xs(:class="props.ui?.description ?? 'text-muted'") {{ (props.description ?? $languages.items[$languages.value].components.file.description) }}
|
|
15
|
+
slot(name='error' :error='error')
|
|
16
|
+
p.mt-1.text-xs(v-if='error' role='alert' :class="props.ui?.error ?? 'text-error'") {{ error }}
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang='ts'>
|
|
20
|
+
import type { FileItem } from '../../types/file'
|
|
21
|
+
|
|
22
|
+
const { $languages } = useNuxtApp()
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
accept?: string,
|
|
26
|
+
multiple?: boolean,
|
|
27
|
+
maxSize?: number,
|
|
28
|
+
disabled?: boolean,
|
|
29
|
+
label?: string,
|
|
30
|
+
description?: string,
|
|
31
|
+
ui?: Partial<Record<'dropzone' | 'icon' | 'button' | 'description' | 'error', string>>
|
|
32
|
+
}>(), {
|
|
33
|
+
accept: 'image/*,.pdf,.doc,.docx,.hwp',
|
|
34
|
+
multiple: true,
|
|
35
|
+
maxSize: 10 * 1024 * 1024,
|
|
36
|
+
disabled: false
|
|
37
|
+
})
|
|
38
|
+
const emit = defineEmits<{ upload: [file: FileItem & { data: string, size: number }], error: [error: string] }>()
|
|
39
|
+
const inputId = useId()
|
|
40
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
41
|
+
const error = ref<string | null>(null)
|
|
42
|
+
const dragCount = ref(0)
|
|
43
|
+
const isReading = ref(false)
|
|
44
|
+
const readers = new Set<FileReader>()
|
|
45
|
+
const acceptedTypes = computed(() => props.accept.split(',').map(type => type.trim().toLowerCase()).filter(Boolean))
|
|
46
|
+
const fileTypes: Record<string, string> = {
|
|
47
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|
48
|
+
webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', heic: 'image/heic',
|
|
49
|
+
pdf: 'application/pdf', doc: 'application/msword',
|
|
50
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', hwp: 'application/x-hwp'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function open () {
|
|
54
|
+
if(!props.disabled) fileInput.value?.click()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function select (event: Event) {
|
|
58
|
+
const input = event.target as HTMLInputElement
|
|
59
|
+
readFiles(input.files)
|
|
60
|
+
input.value = ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function drop (event: DragEvent) {
|
|
64
|
+
dragCount.value = 0
|
|
65
|
+
readFiles(event.dataTransfer?.files)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readFiles (files?: FileList | null) {
|
|
69
|
+
if(props.disabled || !files) return
|
|
70
|
+
error.value = null
|
|
71
|
+
for(const file of Array.from(files).slice(0, props.multiple ? undefined : 1)) {
|
|
72
|
+
const name = file.name.toLowerCase()
|
|
73
|
+
const type = file.type && file.type !== 'application/octet-stream'
|
|
74
|
+
? file.type.toLowerCase() : fileTypes[name.split('.').pop() ?? ''] ?? 'application/octet-stream'
|
|
75
|
+
if(acceptedTypes.value.length && !acceptedTypes.value.some(accept => accept.startsWith('.')
|
|
76
|
+
? name.endsWith(accept) : accept.endsWith('/*') ? type.startsWith(accept.slice(0, -1)) : type === accept)) {
|
|
77
|
+
error.value = $languages.items[$languages.value].components.file.unsupported.replace('${name}', file.name).replace('${size}', String(props.maxSize))
|
|
78
|
+
emit('error', error.value)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if(file.size > props.maxSize) {
|
|
82
|
+
error.value = $languages.items[$languages.value].components.file.tooLarge.replace('${name}', file.name).replace('${size}', String(props.maxSize))
|
|
83
|
+
emit('error', error.value)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const reader = new FileReader()
|
|
88
|
+
readers.add(reader)
|
|
89
|
+
isReading.value = true
|
|
90
|
+
reader.onload = () => {
|
|
91
|
+
if(typeof reader.result === 'string') emit('upload', { name: file.name, size: file.size, type: type, data: reader.result })
|
|
92
|
+
}
|
|
93
|
+
reader.onerror = () => {
|
|
94
|
+
error.value = $languages.items[$languages.value].components.file.readError.replace('${name}', file.name).replace('${size}', String(props.maxSize))
|
|
95
|
+
emit('error', error.value)
|
|
96
|
+
}
|
|
97
|
+
reader.onloadend = () => {
|
|
98
|
+
readers.delete(reader)
|
|
99
|
+
isReading.value = readers.size > 0
|
|
100
|
+
}
|
|
101
|
+
reader.readAsDataURL(file)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onBeforeUnmount(() => {
|
|
106
|
+
for(const reader of readers) {
|
|
107
|
+
reader.onload = null
|
|
108
|
+
reader.onerror = null
|
|
109
|
+
reader.onloadend = null
|
|
110
|
+
reader.abort()
|
|
111
|
+
}
|
|
112
|
+
readers.clear()
|
|
113
|
+
})
|
|
114
|
+
</script>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
UCalendar(:locale='$languages.locale.code' :calendar-label='$languages.items[$languages.value].components.calendar.label' v-bind='$attrs')
|
|
3
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
4
|
+
slot(:name='name' v-bind='scope || {}')
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
const { $languages } = useNuxtApp()
|
|
9
|
+
defineOptions({ inheritAttrs: false })
|
|
10
|
+
</script>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
button.relative.flex.size-4.shrink-0.cursor-pointer.items-center.justify-center.rounded.border.transition-colors(
|
|
3
|
+
type='button'
|
|
4
|
+
role='checkbox'
|
|
5
|
+
:aria-checked='modelValue'
|
|
6
|
+
:disabled='props.disabled'
|
|
7
|
+
:class="modelValue ? (props.ui?.checked ?? 'border-primary bg-primary text-white') : (props.ui?.unchecked ?? 'border-accented bg-default text-transparent')"
|
|
8
|
+
class='focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:opacity-50'
|
|
9
|
+
@click='toggle'
|
|
10
|
+
)
|
|
11
|
+
slot(:checked='modelValue')
|
|
12
|
+
i.check-mark.pointer-events-none.absolute.block.origin-left(:class="[props.ui?.icon, { active: modelValue }]" class='left-[5.83px] top-[4.14px] size-[14px] -rotate-45 scale-[0.8]' aria-hidden='true')
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup lang='ts'>
|
|
16
|
+
const props = defineProps<{ disabled?: boolean, ui?: Partial<Record<'checked' | 'unchecked' | 'icon', string>> }>()
|
|
17
|
+
const modelValue = defineModel<boolean>({ default: false })
|
|
18
|
+
const emit = defineEmits<{ update: [value: boolean] }>()
|
|
19
|
+
|
|
20
|
+
function toggle () {
|
|
21
|
+
if(props.disabled) return
|
|
22
|
+
modelValue.value = !modelValue.value
|
|
23
|
+
emit('update', modelValue.value)
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<style scoped>
|
|
28
|
+
.check-mark::before,
|
|
29
|
+
.check-mark::after {
|
|
30
|
+
content: '';
|
|
31
|
+
display: block;
|
|
32
|
+
background: currentColor;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.check-mark::before {
|
|
36
|
+
width: 2.1px;
|
|
37
|
+
height: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.check-mark::after {
|
|
41
|
+
width: 0;
|
|
42
|
+
height: 2.1px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.check-mark.active::before {
|
|
46
|
+
height: 35%;
|
|
47
|
+
margin-bottom: -.1px;
|
|
48
|
+
border-radius: 4px 4px 0 0;
|
|
49
|
+
transition: height 300ms cubic-bezier(.895, .03, .685, .22);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.check-mark.active::after {
|
|
53
|
+
width: 80%;
|
|
54
|
+
border-radius: 0 4px 4px 0;
|
|
55
|
+
transition: width 300ms 300ms cubic-bezier(.165, .84, .44, 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@media (prefers-reduced-motion: reduce) {
|
|
59
|
+
.check-mark.active::before,
|
|
60
|
+
.check-mark.active::after {
|
|
61
|
+
transition: none;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
ClientOnly(fallback-tag='div' :fallback='$languages.items[$languages.value].components.editor.loading')
|
|
3
|
+
div.overflow-hidden.rounded-md.border.border-accented.bg-default.text-default(v-bind='$attrs' :class="[{ 'opacity-50': props.disabled }, props.ui?.root]" class='focus-within:border-primary focus-within:ring-1 focus-within:ring-primary')
|
|
4
|
+
div.flex.flex-wrap.gap-1.border-b.border-default.bg-muted.p-2(v-if='!props.readonly' role='group' :aria-label='(props.toolbarLabel ?? $languages.items[$languages.value].components.editor.toolbar)' :class='props.ui?.toolbar')
|
|
5
|
+
Tooltip(v-for='action in actions' :key='action.name' :text='action.label')
|
|
6
|
+
Button.w-8.px-0(:auto-loading='false' :disabled='props.disabled || !editor || action.disabled' :aria-label='action.label' :aria-pressed='action.active' :class="action.active ? 'bg-primary/10 text-primary' : 'text-muted hover:bg-elevated hover:text-default'" @click='action.run')
|
|
7
|
+
Icon.size-4.shrink-0(:name='action.icon')
|
|
8
|
+
slot(name='toolbar' :editor='editor')
|
|
9
|
+
form.flex.flex-wrap.items-start.gap-2.border-b.border-default.p-2(v-if='showLink && !props.readonly' @submit.prevent='setLink')
|
|
10
|
+
div.min-w-0.flex-1
|
|
11
|
+
Input(v-model='link' type='text' inputmode='url' placeholder='https://example.com' :aria-label='$languages.items[$languages.value].components.editor.linkAddress' :disabled='props.disabled' :aria-invalid='!!linkError')
|
|
12
|
+
p.mt-1.text-xs.text-error(v-if='linkError' role='alert') {{ $languages.items[$languages.value].components.editor.invalidLink }}
|
|
13
|
+
Button.bg-primary.text-white(:auto-loading='false' type='submit' :disabled='props.disabled' class='hover:bg-primary-700') {{ $languages.items[$languages.value].components.common.apply }}
|
|
14
|
+
Button.text-muted(:auto-loading='false' :disabled='props.disabled' @click="link = ''; setLink()") {{ $languages.items[$languages.value].components.editor.unlink }}
|
|
15
|
+
Button.text-muted(:auto-loading='false' @click='showLink = false') {{ $languages.items[$languages.value].components.common.cancel }}
|
|
16
|
+
div.relative(:class='props.ui?.content')
|
|
17
|
+
p.pointer-events-none.absolute.left-4.top-4.text-sm.text-dimmed(v-if='editor?.isEmpty && (props.placeholder ?? $languages.items[$languages.value].components.editor.placeholder)' aria-hidden='true') {{ (props.placeholder ?? $languages.items[$languages.value].components.editor.placeholder) }}
|
|
18
|
+
EditorContent(:editor='editor')
|
|
19
|
+
div.min-h-40.p-4(v-if='!editor' aria-busy='true')
|
|
20
|
+
Loader.size-4.text-primary
|
|
21
|
+
div.border-t.border-default.px-4.py-2.text-xs.text-muted(v-if='$slots.footer')
|
|
22
|
+
slot(name='footer' :editor='editor')
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang='ts'>
|
|
26
|
+
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
27
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
28
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
29
|
+
|
|
30
|
+
const { $languages } = useNuxtApp()
|
|
31
|
+
|
|
32
|
+
defineOptions({ inheritAttrs: false })
|
|
33
|
+
|
|
34
|
+
const props = withDefaults(defineProps<{
|
|
35
|
+
mode?: 'simple' | 'detailed',
|
|
36
|
+
disabled?: boolean,
|
|
37
|
+
readonly?: boolean,
|
|
38
|
+
placeholder?: string,
|
|
39
|
+
label?: string,
|
|
40
|
+
toolbarLabel?: string,
|
|
41
|
+
ui?: Partial<Record<'root' | 'toolbar' | 'content', string>>
|
|
42
|
+
}>(), { mode: 'simple' })
|
|
43
|
+
const modelValue = defineModel<string>({ default: '' })
|
|
44
|
+
const revision = ref(0)
|
|
45
|
+
const showLink = ref(false)
|
|
46
|
+
const link = ref('')
|
|
47
|
+
const linkError = ref(false)
|
|
48
|
+
let selection = { from: 0, to: 0 }
|
|
49
|
+
const editor = useEditor({
|
|
50
|
+
content: modelValue.value,
|
|
51
|
+
editable: !props.disabled && !props.readonly,
|
|
52
|
+
extensions: [StarterKit.configure({ link: { openOnClick: false, protocols: ['http', 'https', 'mailto'] } }), TextAlign.configure({ types: ['heading', 'paragraph'] })],
|
|
53
|
+
editorProps: {
|
|
54
|
+
attributes: {
|
|
55
|
+
role: 'textbox',
|
|
56
|
+
'aria-multiline': 'true',
|
|
57
|
+
'aria-label': (props.label ?? $languages.items[$languages.value].components.editor.label),
|
|
58
|
+
'aria-disabled': String(!!props.disabled),
|
|
59
|
+
'aria-readonly': String(!!props.readonly),
|
|
60
|
+
class: 'min-h-40 p-4 text-sm leading-6 outline-none break-words [&_p]:min-h-6 [&_p+p]:mt-2 [&_h1]:text-2xl [&_h2]:text-xl [&_h3]:text-lg [&_h1]:font-bold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:text-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto [&_a]:text-primary [&_a]:underline [&_hr]:border-default [&_hr]:my-4'
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
onUpdate: ({ editor }) => { modelValue.value = editor.getHTML() },
|
|
64
|
+
onTransaction: () => { revision.value++ },
|
|
65
|
+
onCreate: ({ editor }) => {
|
|
66
|
+
if(editor.getHTML() != modelValue.value) editor.commands.setContent(modelValue.value, { emitUpdate: false })
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
watch(modelValue, value => {
|
|
70
|
+
if(editor.value && value != editor.value.getHTML()) editor.value.commands.setContent(value, { emitUpdate: false })
|
|
71
|
+
})
|
|
72
|
+
watch(() => [props.disabled, props.readonly, (props.label ?? $languages.items[$languages.value].components.editor.label)], () => {
|
|
73
|
+
editor.value?.setEditable(!props.disabled && !props.readonly)
|
|
74
|
+
editor.value?.setOptions({ editorProps: { attributes: { ...editor.value.options.editorProps.attributes, 'aria-label': (props.label ?? $languages.items[$languages.value].components.editor.label), 'aria-disabled': String(!!props.disabled), 'aria-readonly': String(!!props.readonly) } } })
|
|
75
|
+
if(props.disabled || props.readonly) showLink.value = false
|
|
76
|
+
})
|
|
77
|
+
const actions = computed(() => {
|
|
78
|
+
revision.value
|
|
79
|
+
const instance = editor.value
|
|
80
|
+
if(!instance) return []
|
|
81
|
+
const items = [
|
|
82
|
+
{ name: 'bold', label: $languages.items[$languages.value].components.editor.bold, icon: 'i-lucide-bold', active: instance.isActive('bold'), disabled: false, run: () => instance.chain().focus().toggleBold().run() },
|
|
83
|
+
{ name: 'italic', label: $languages.items[$languages.value].components.editor.italic, icon: 'i-lucide-italic', active: instance.isActive('italic'), disabled: false, run: () => instance.chain().focus().toggleItalic().run() },
|
|
84
|
+
{ name: 'underline', label: $languages.items[$languages.value].components.editor.underline, icon: 'i-lucide-underline', active: instance.isActive('underline'), disabled: false, run: () => instance.chain().focus().toggleUnderline().run() },
|
|
85
|
+
{ name: 'bulletList', label: $languages.items[$languages.value].components.editor.bulletList, icon: 'i-lucide-list', active: instance.isActive('bulletList'), disabled: false, run: () => instance.chain().focus().toggleBulletList().run() }
|
|
86
|
+
]
|
|
87
|
+
if(props.mode == 'detailed') items.push(
|
|
88
|
+
{ name: 'heading', label: $languages.items[$languages.value].components.editor.heading, icon: 'i-lucide-heading-2', active: instance.isActive('heading', { level: 2 }), disabled: false, run: () => instance.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
89
|
+
{ name: 'strike', label: $languages.items[$languages.value].components.editor.strike, icon: 'i-lucide-strikethrough', active: instance.isActive('strike'), disabled: false, run: () => instance.chain().focus().toggleStrike().run() },
|
|
90
|
+
{ name: 'orderedList', label: $languages.items[$languages.value].components.editor.orderedList, icon: 'i-lucide-list-ordered', active: instance.isActive('orderedList'), disabled: false, run: () => instance.chain().focus().toggleOrderedList().run() },
|
|
91
|
+
{ name: 'blockquote', label: $languages.items[$languages.value].components.editor.blockquote, icon: 'i-lucide-quote', active: instance.isActive('blockquote'), disabled: false, run: () => instance.chain().focus().toggleBlockquote().run() },
|
|
92
|
+
{ name: 'codeBlock', label: $languages.items[$languages.value].components.editor.codeBlock, icon: 'i-lucide-code', active: instance.isActive('codeBlock'), disabled: false, run: () => instance.chain().focus().toggleCodeBlock().run() },
|
|
93
|
+
{ name: 'left', label: $languages.items[$languages.value].components.editor.left, icon: 'i-lucide-align-left', active: instance.isActive({ textAlign: 'left' }), disabled: false, run: () => instance.chain().focus().setTextAlign('left').run() },
|
|
94
|
+
{ name: 'center', label: $languages.items[$languages.value].components.editor.center, icon: 'i-lucide-align-center', active: instance.isActive({ textAlign: 'center' }), disabled: false, run: () => instance.chain().focus().setTextAlign('center').run() },
|
|
95
|
+
{ name: 'right', label: $languages.items[$languages.value].components.editor.right, icon: 'i-lucide-align-right', active: instance.isActive({ textAlign: 'right' }), disabled: false, run: () => instance.chain().focus().setTextAlign('right').run() },
|
|
96
|
+
{ name: 'link', label: $languages.items[$languages.value].components.editor.link, icon: 'i-lucide-link', active: instance.isActive('link'), disabled: false, run: () => {
|
|
97
|
+
selection = { from: instance.state.selection.from, to: instance.state.selection.to }
|
|
98
|
+
link.value = instance.getAttributes('link').href || ''
|
|
99
|
+
linkError.value = false
|
|
100
|
+
showLink.value = !showLink.value
|
|
101
|
+
return true
|
|
102
|
+
} },
|
|
103
|
+
{ name: 'clear', label: $languages.items[$languages.value].components.editor.clear, icon: 'i-lucide-remove-formatting', active: false, disabled: false, run: () => instance.chain().focus().unsetAllMarks().clearNodes().run() }
|
|
104
|
+
)
|
|
105
|
+
items.push(
|
|
106
|
+
{ name: 'undo', label: $languages.items[$languages.value].components.editor.undo, icon: 'i-lucide-undo', active: false, disabled: !instance.can().undo(), run: () => instance.chain().focus().undo().run() },
|
|
107
|
+
{ name: 'redo', label: $languages.items[$languages.value].components.editor.redo, icon: 'i-lucide-redo', active: false, disabled: !instance.can().redo(), run: () => instance.chain().focus().redo().run() }
|
|
108
|
+
)
|
|
109
|
+
return items
|
|
110
|
+
})
|
|
111
|
+
function setLink () {
|
|
112
|
+
if(props.disabled || props.readonly || !editor.value) return
|
|
113
|
+
const href = link.value.trim()
|
|
114
|
+
if(href && !/^(https?:\/\/|mailto:)/i.test(href)) {
|
|
115
|
+
linkError.value = true
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const command = editor.value.chain().focus().setTextSelection(selection).extendMarkRange('link')
|
|
119
|
+
if(href) command.setLink({ href }).run()
|
|
120
|
+
else command.unsetLink().run()
|
|
121
|
+
showLink.value = false
|
|
122
|
+
}
|
|
123
|
+
defineExpose({ editor })
|
|
124
|
+
</script>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
div(:class="[{ 'input-error': hasError }, props.ui?.root]" @input="error = null" @change="error = null")
|
|
3
|
+
slot(:error='error' :clear='() => error = null')
|
|
4
|
+
Transition(name='input-error-message')
|
|
5
|
+
div(v-if='hasError')
|
|
6
|
+
slot(name='message' :error='error')
|
|
7
|
+
p.mt-1.text-xs(:id='messageId' role='alert' :class="props.ui?.message ?? 'text-error'") {{ error }}
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang='ts'>
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
ui?: Partial<Record<'root' | 'message', string>>,
|
|
13
|
+
messageId?: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const error = defineModel<string | null>('error', { default: null })
|
|
17
|
+
const hasError = computed(() => typeof error.value === 'string' && error.value.length > 0)
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<style scoped>
|
|
21
|
+
.input-error :deep(input) {
|
|
22
|
+
border-color: var(--ui-error) !important;
|
|
23
|
+
border-width: 1px !important;
|
|
24
|
+
box-shadow: 0 0 0 1px var(--ui-error);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.input-error :deep([data-input-root]),
|
|
28
|
+
.input-error :deep(input:not([data-input-root] input)) {
|
|
29
|
+
animation: input-error-shake 280ms cubic-bezier(.36, .07, .19, .97);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.input-error-message-enter-active,
|
|
33
|
+
.input-error-message-leave-active {
|
|
34
|
+
transition: opacity 160ms ease, transform 160ms ease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.input-error-message-enter-from,
|
|
38
|
+
.input-error-message-leave-to {
|
|
39
|
+
opacity: 0;
|
|
40
|
+
transform: translateY(-.25rem);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@keyframes input-error-shake {
|
|
44
|
+
15%, 85% { transform: translateX(-.2rem); }
|
|
45
|
+
30%, 70% { transform: translateX(.2rem); }
|
|
46
|
+
45%, 55% { transform: translateX(-.1rem); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
.input-error :deep([data-input-root]),
|
|
51
|
+
.input-error :deep(input),
|
|
52
|
+
.input-error-message-enter-active,
|
|
53
|
+
.input-error-message-leave-active {
|
|
54
|
+
animation: none;
|
|
55
|
+
transition: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
div.relative.w-full(data-input-root :class='props.ui?.root')
|
|
3
|
+
div.absolute.inset-y-0.left-3.flex.items-center(v-if='props.icon || $slots.leading' :class="props.ui?.leading ?? 'text-muted'")
|
|
4
|
+
slot(name='leading')
|
|
5
|
+
Icon.pointer-events-none.size-4(v-if='props.icon' :name='props.icon')
|
|
6
|
+
input.h-8.w-full.rounded-md.border.px-3.text-sm.outline-none.transition-colors(
|
|
7
|
+
v-bind='mergeProps(languageAttributes, $attrs)'
|
|
8
|
+
v-model='modelValue'
|
|
9
|
+
v-maska
|
|
10
|
+
:data-maska='props.vMask'
|
|
11
|
+
:class="[props.ui?.input ?? 'border-accented bg-default text-default placeholder:text-dimmed focus:border-current focus:ring-1 focus:ring-current', { 'pl-9': props.icon || $slots.leading, 'pr-9': $slots.trailing }]"
|
|
12
|
+
class='disabled:cursor-not-allowed disabled:opacity-50'
|
|
13
|
+
)
|
|
14
|
+
div.absolute.inset-y-0.right-3.flex.items-center(v-if='$slots.trailing' :class="props.ui?.trailing ?? 'text-muted'")
|
|
15
|
+
slot(name='trailing')
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang='ts'>
|
|
19
|
+
import { mergeProps } from 'vue'
|
|
20
|
+
import { vMaska } from 'maska/vue'
|
|
21
|
+
|
|
22
|
+
const { $languages } = useNuxtApp()
|
|
23
|
+
|
|
24
|
+
defineOptions({ inheritAttrs: false })
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
icon?: string,
|
|
27
|
+
vMask?: string,
|
|
28
|
+
languageKey?: string,
|
|
29
|
+
ui?: Partial<Record<'root' | 'input' | 'leading' | 'trailing', string>>
|
|
30
|
+
}>()
|
|
31
|
+
const modelValue = defineModel<string>({ default: '' })
|
|
32
|
+
|
|
33
|
+
const languageAttributes = computed<Record<string, unknown>>(() => {
|
|
34
|
+
if(!props.languageKey) return {}
|
|
35
|
+
let attributes: unknown = $languages.items[$languages.value]?.input
|
|
36
|
+
for(const key of props.languageKey.split('.').filter(Boolean)) {
|
|
37
|
+
if(attributes == null || typeof attributes !== 'object' || Array.isArray(attributes)) return {}
|
|
38
|
+
attributes = (attributes as Record<string, unknown>)[key]
|
|
39
|
+
}
|
|
40
|
+
return attributes != null && typeof attributes === 'object' && !Array.isArray(attributes)
|
|
41
|
+
? attributes as Record<string, unknown> : {}
|
|
42
|
+
})
|
|
43
|
+
</script>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
USelect(size='md' :placeholder='$languages.items[$languages.value].components.common.select' v-bind='$attrs')
|
|
3
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
4
|
+
slot(:name='name' v-bind='scope || {}')
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
const { $languages } = useNuxtApp()
|
|
9
|
+
defineOptions({ inheritAttrs: false })
|
|
10
|
+
</script>
|