adminforth 2.7.18 → 2.8.0
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/commands/createApp/templates/index.ts.hbs +2 -1
- package/commands/createCustomComponent/main.js +1 -0
- package/commands/createCustomComponent/templates/customCrud/beforeActionButtons.vue.hbs +38 -0
- package/commands/createPlugin/templates/custom/tsconfig.json.hbs +2 -5
- package/dist/dataConnectors/baseConnector.d.ts.map +1 -1
- package/dist/dataConnectors/baseConnector.js +33 -15
- package/dist/dataConnectors/baseConnector.js.map +1 -1
- package/dist/dataConnectors/clickhouse.d.ts.map +1 -1
- package/dist/dataConnectors/clickhouse.js +15 -0
- package/dist/dataConnectors/clickhouse.js.map +1 -1
- package/dist/dataConnectors/mongo.d.ts.map +1 -1
- package/dist/dataConnectors/mongo.js +30 -1
- package/dist/dataConnectors/mongo.js.map +1 -1
- package/dist/dataConnectors/mysql.d.ts.map +1 -1
- package/dist/dataConnectors/mysql.js +11 -0
- package/dist/dataConnectors/mysql.js.map +1 -1
- package/dist/dataConnectors/postgres.d.ts.map +1 -1
- package/dist/dataConnectors/postgres.js +11 -0
- package/dist/dataConnectors/postgres.js.map +1 -1
- package/dist/dataConnectors/sqlite.d.ts.map +1 -1
- package/dist/dataConnectors/sqlite.js +11 -0
- package/dist/dataConnectors/sqlite.js.map +1 -1
- package/dist/modules/codeInjector.d.ts +1 -0
- package/dist/modules/codeInjector.d.ts.map +1 -1
- package/dist/modules/codeInjector.js +4 -0
- package/dist/modules/codeInjector.js.map +1 -1
- package/dist/modules/configValidator.d.ts.map +1 -1
- package/dist/modules/configValidator.js +6 -2
- package/dist/modules/configValidator.js.map +1 -1
- package/dist/modules/restApi.d.ts.map +1 -1
- package/dist/modules/restApi.js +2 -0
- package/dist/modules/restApi.js.map +1 -1
- package/dist/modules/styles.d.ts +8 -1
- package/dist/modules/styles.d.ts.map +1 -1
- package/dist/modules/styles.js +9 -2
- package/dist/modules/styles.js.map +1 -1
- package/dist/spa/src/App.vue +12 -4
- package/dist/spa/src/adminforth.ts +31 -11
- package/dist/spa/src/afcl/BarChart.vue +2 -2
- package/dist/spa/src/afcl/Checkbox.vue +2 -2
- package/dist/spa/src/afcl/Dialog.vue +38 -21
- package/dist/spa/src/afcl/Dropzone.vue +2 -2
- package/dist/spa/src/afcl/Input.vue +1 -1
- package/dist/spa/src/afcl/LinkButton.vue +1 -1
- package/dist/spa/src/afcl/PieChart.vue +5 -5
- package/dist/spa/src/afcl/Select.vue +17 -12
- package/dist/spa/src/afcl/Table.vue +202 -72
- package/dist/spa/src/afcl/Textarea.vue +31 -0
- package/dist/spa/src/afcl/Toggle.vue +2 -2
- package/dist/spa/src/afcl/Tooltip.vue +0 -1
- package/dist/spa/src/afcl/index.ts +1 -1
- package/dist/spa/src/components/AcceptModal.vue +1 -1
- package/dist/spa/src/components/ColumnValueInput.vue +11 -11
- package/dist/spa/src/components/ColumnValueInputWrapper.vue +2 -2
- package/dist/spa/src/components/CustomRangePicker.vue +5 -5
- package/dist/spa/src/components/ErrorMessage.vue +21 -0
- package/dist/spa/src/components/Filters.vue +7 -6
- package/dist/spa/src/components/GroupsTable.vue +2 -1
- package/dist/spa/src/components/MenuLink.vue +3 -3
- package/dist/spa/src/components/ResourceForm.vue +35 -27
- package/dist/spa/src/components/ResourceListTable.vue +42 -42
- package/dist/spa/src/components/ResourceListTableVirtual.vue +41 -41
- package/dist/spa/src/components/ShowTable.vue +3 -3
- package/dist/spa/src/components/SkeleteLoader.vue +2 -2
- package/dist/spa/src/components/ThreeDotsMenu.vue +69 -10
- package/dist/spa/src/components/Toast.vue +25 -2
- package/dist/spa/src/components/ValueRenderer.vue +39 -12
- package/dist/spa/src/i18n.ts +1 -1
- package/dist/spa/src/shims-vue.d.ts +5 -0
- package/dist/spa/src/spa_types/core.ts +1 -1
- package/dist/spa/src/stores/modal.ts +6 -1
- package/dist/spa/src/stores/toast.ts +22 -3
- package/dist/spa/src/types/Back.ts +31 -6
- package/dist/spa/src/types/Common.ts +33 -24
- package/dist/spa/src/types/FrontendAPI.ts +21 -5
- package/dist/spa/src/types/adapters/EmailAdapter.ts +2 -4
- package/dist/spa/src/types/adapters/ImageVisionAdapter.ts +30 -0
- package/dist/spa/src/types/adapters/index.ts +1 -0
- package/dist/spa/src/utils.ts +8 -7
- package/dist/spa/src/views/CreateView.vue +15 -16
- package/dist/spa/src/views/EditView.vue +23 -17
- package/dist/spa/src/views/ListView.vue +116 -66
- package/dist/spa/src/views/LoginView.vue +2 -9
- package/dist/spa/src/views/ResourceParent.vue +1 -1
- package/dist/spa/src/views/ShowView.vue +59 -39
- package/dist/spa/src/websocket.ts +6 -1
- package/dist/spa/tsconfig.app.json +1 -1
- package/dist/spa/vite.config.ts +45 -2
- package/dist/types/Back.d.ts +16 -1
- package/dist/types/Back.d.ts.map +1 -1
- package/dist/types/Back.js +15 -0
- package/dist/types/Back.js.map +1 -1
- package/dist/types/Common.d.ts +27 -22
- package/dist/types/Common.d.ts.map +1 -1
- package/dist/types/Common.js.map +1 -1
- package/dist/types/FrontendAPI.d.ts +21 -3
- package/dist/types/FrontendAPI.d.ts.map +1 -1
- package/dist/types/FrontendAPI.js.map +1 -1
- package/dist/types/adapters/EmailAdapter.d.ts +2 -3
- package/dist/types/adapters/EmailAdapter.d.ts.map +1 -1
- package/dist/types/adapters/ImageVisionAdapter.d.ts +25 -0
- package/dist/types/adapters/ImageVisionAdapter.d.ts.map +1 -0
- package/dist/types/adapters/ImageVisionAdapter.js +2 -0
- package/dist/types/adapters/ImageVisionAdapter.js.map +1 -0
- package/dist/types/adapters/index.d.ts +1 -0
- package/dist/types/adapters/index.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -1,79 +1,135 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
<div class="
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
2
|
+
<div class="afcl-table-container relative overflow-x-auto shadow-md sm:rounded-lg">
|
|
3
|
+
<div class="overflow-x-auto w-full">
|
|
4
|
+
<table class="afcl-table w-full text-sm text-left rtl:text-right text-lightTableText dark:text-darkTableText overflow-x-auto">
|
|
5
|
+
<thead class="afcl-table-thread text-xs text-lightTableHeadingText uppercase bg-lightTableHeadingBackground dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
|
|
6
|
+
<tr>
|
|
7
|
+
<th scope="col" class="px-6 py-3" ref="headerRefs" :key="`header-${column.fieldName}`"
|
|
8
|
+
v-for="column in columns"
|
|
9
|
+
>
|
|
10
|
+
<slot v-if="$slots[`header:${column.fieldName}`]" :name="`header:${column.fieldName}`" :column="column" />
|
|
11
|
+
|
|
12
|
+
<span v-else>
|
|
13
|
+
{{ column.label }}
|
|
14
|
+
</span>
|
|
15
|
+
</th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<SkeleteLoader
|
|
20
|
+
v-if="isLoading || props.isLoading"
|
|
21
|
+
:rows="pageSize"
|
|
22
|
+
:columns="columns.length"
|
|
23
|
+
:row-heights="rowHeights"
|
|
24
|
+
:column-widths="columnWidths"
|
|
25
|
+
/>
|
|
26
|
+
<tr v-else-if="!isLoading && !props.isLoading && dataPage.length === 0" class="afcl-table-empty-body">
|
|
27
|
+
<td :colspan="columns.length" class="px-6 py-12 text-center">
|
|
28
|
+
<div class="flex flex-col items-center justify-center text-lightTableText dark:text-darkTableText">
|
|
29
|
+
<IconTableRowOutline class="w-10 h-10 mb-4 text-gray-400 dark:text-gray-500" />
|
|
30
|
+
<p class="text-md">{{ $t('No data available') }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<tr
|
|
35
|
+
v-else="!isLoading && !props.isLoading"
|
|
36
|
+
v-for="(item, index) in dataPage"
|
|
37
|
+
:key="`row-${index}`"
|
|
38
|
+
ref="rowRefs"
|
|
39
|
+
:class="{
|
|
40
|
+
'afcl-table-body odd:bg-lightTableOddBackground odd:dark:bg-darkTableOddBackground even:bg-lightTableEvenBackground even:dark:bg-darkTableEvenBackground': evenHighlights,
|
|
41
|
+
'border-b border-lightTableBorder dark:border-darkTableBorder': index !== dataPage.length - 1 || totalPages > 1,
|
|
42
|
+
}"
|
|
32
43
|
>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
<td class="px-6 py-4" :key="`cell-${index}-${column.fieldName}`"
|
|
45
|
+
v-for="column in props.columns"
|
|
46
|
+
>
|
|
47
|
+
<slot v-if="$slots[`cell:${column.fieldName}`]"
|
|
48
|
+
:name="`cell:${column.fieldName}`"
|
|
49
|
+
:item="item" :column="column"
|
|
50
|
+
>
|
|
51
|
+
</slot>
|
|
52
|
+
<span v-else-if="!isLoading || props.isLoading" >
|
|
53
|
+
{{ item[column.fieldName] }}
|
|
54
|
+
</span>
|
|
55
|
+
<div v-else>
|
|
56
|
+
<div class=" w-full">
|
|
57
|
+
<Skeleton class="h-4" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</div>
|
|
65
|
+
<nav class="afcl-table-pagination-container bg-lightTableBackground dark:bg-darkTableBackground mt-2 flex flex-col gap-2 items-center sm:flex-row justify-center sm:justify-between px-4 pb-4"
|
|
66
|
+
v-if="totalPages > 1"
|
|
67
|
+
:aria-label="$t('Table navigation')">
|
|
44
68
|
<i18n-t
|
|
45
|
-
keypath="Showing {from} to {to} of {total}" tag="span" class="afcl-table-pagination-text text-sm font-normal text-lightTablePaginationText dark:text-darkTablePaginationText mb-4 md:mb-0 block w-full md:inline md:w-auto"
|
|
69
|
+
keypath="Showing {from} to {to} of {total}" tag="span" class="afcl-table-pagination-text text-sm font-normal text-center text-lightTablePaginationText dark:text-darkTablePaginationText sm:mb-4 md:mb-0 block w-full md:inline md:w-auto"
|
|
46
70
|
>
|
|
47
|
-
<template #from><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ Math.min((currentPage - 1) * props.pageSize + 1,
|
|
48
|
-
<template #to><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ Math.min(currentPage * props.pageSize,
|
|
49
|
-
<template #total><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{
|
|
71
|
+
<template #from><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ Math.min((currentPage - 1) * props.pageSize + 1, dataResult.total) }}</span></template>
|
|
72
|
+
<template #to><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ Math.min(currentPage * props.pageSize, dataResult.total) }}</span></template>
|
|
73
|
+
<template #total><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ dataResult.total }}</span></template>
|
|
50
74
|
</i18n-t>
|
|
75
|
+
<div class="af-pagination-container flex flex-row items-center xs:flex-row xs:justify-between xs:items-center gap-3">
|
|
76
|
+
<div class="inline-flex">
|
|
77
|
+
<!-- Buttons -->
|
|
78
|
+
<button
|
|
79
|
+
class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText bg-lightActivePaginationButtonBackground border-r-0 rounded-s hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
|
|
80
|
+
@click="currentPage--; pageInput = currentPage.toString();" :disabled="currentPage <= 1">
|
|
81
|
+
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
82
|
+
viewBox="0 0 14 10">
|
|
83
|
+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
84
|
+
d="M13 5H1m0 0 4 4M1 5l4-4"/>
|
|
85
|
+
</svg>
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-r-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
|
|
89
|
+
@click="switchPage(1); pageInput = currentPage.toString();" :disabled="currentPage <= 1">
|
|
90
|
+
<!-- <IconChevronDoubleLeftOutline class="w-4 h-4" /> -->
|
|
91
|
+
1
|
|
92
|
+
</button>
|
|
93
|
+
<div
|
|
94
|
+
contenteditable="true"
|
|
95
|
+
class="min-w-10 outline-none inline-block w-auto py-1.5 px-3 text-sm text-center text-lightTablePaginationInputText border border-lightTablePaginationInputBorder bg-lightTablePaginationInputBackground dark:border-darkTablePaginationInputBorder dark:text-darkTablePaginationInputText dark:bg-darkTablePaginationInputBackground z-10"
|
|
96
|
+
@keydown="onPageKeydown($event)"
|
|
97
|
+
@input="onPageInput($event)"
|
|
98
|
+
@blur="validatePageInput()"
|
|
99
|
+
>
|
|
100
|
+
{{ pageInput }}
|
|
101
|
+
</div>
|
|
51
102
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
:aria-current="page === page ? 'page' : undefined"
|
|
57
|
-
:class='{
|
|
58
|
-
"afcl-table-pagination-button text-blue-600 bg-lightActivePaginationButtonBackground text-lightActivePaginationButtonText dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText hover:opacity-90": page === currentPage,
|
|
59
|
-
"text-lightUnactivePaginationButtonText border bg-lightUnactivePaginationButtonBackground border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:border-darkUnactivePaginationButtonBorder dark:text-darkUnactivePaginationButtonText dark:hover:bg-darkUnactivePaginationButtonHoverBackground dark:hover:text-darkUnactivePaginationButtonHoverText": page !== currentPage,
|
|
60
|
-
"rounded-s-lg ms-0": page === 1,
|
|
61
|
-
"rounded-e-lg": page === totalPages,
|
|
62
|
-
}'
|
|
63
|
-
class="flex items-center justify-center px-3 h-8 leading-tight ">
|
|
64
|
-
{{ page }}
|
|
65
|
-
</a>
|
|
66
|
-
</li>
|
|
67
|
-
</ul>
|
|
68
|
-
</nav>
|
|
69
|
-
</div>
|
|
70
|
-
|
|
103
|
+
<button
|
|
104
|
+
class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-l-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
|
|
105
|
+
@click="currentPage = totalPages; pageInput = currentPage.toString();" :disabled="currentPage >= totalPages">
|
|
106
|
+
{{ totalPages }}
|
|
71
107
|
|
|
72
|
-
|
|
108
|
+
<!-- <IconChevronDoubleRightOutline class="w-4 h-4" /> -->
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText focus:outline-none bg-lightActivePaginationButtonBackground border-l-0 rounded-e hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
|
|
112
|
+
@click="currentPage++; pageInput = currentPage.toString();" :disabled="currentPage >= totalPages">
|
|
113
|
+
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
114
|
+
viewBox="0 0 14 10">
|
|
115
|
+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
116
|
+
d="M1 5h12m0 0L9 1m4 4L9 9"/>
|
|
117
|
+
</svg>
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</nav>
|
|
122
|
+
</div>
|
|
73
123
|
</template>
|
|
74
124
|
|
|
75
125
|
<script setup lang="ts">
|
|
76
|
-
import { ref,
|
|
126
|
+
import { ref, computed, useTemplateRef, watch, onMounted } from 'vue';
|
|
127
|
+
import SkeleteLoader from '@/components/SkeleteLoader.vue';
|
|
128
|
+
import { IconTableRowOutline } from '@iconify-prerendered/vue-flowbite';
|
|
129
|
+
|
|
130
|
+
defineExpose({
|
|
131
|
+
refreshTable
|
|
132
|
+
})
|
|
77
133
|
|
|
78
134
|
const props = withDefaults(
|
|
79
135
|
defineProps<{
|
|
@@ -83,34 +139,108 @@
|
|
|
83
139
|
}[],
|
|
84
140
|
data: {
|
|
85
141
|
[key: string]: any,
|
|
86
|
-
}[],
|
|
142
|
+
}[] | ((params: { offset: number, limit: number }) => Promise<{data: {[key: string]: any}[], total: number}>),
|
|
87
143
|
evenHighlights?: boolean,
|
|
88
144
|
pageSize?: number,
|
|
145
|
+
isLoading?: boolean,
|
|
89
146
|
}>(), {
|
|
90
147
|
evenHighlights: true,
|
|
91
|
-
pageSize:
|
|
148
|
+
pageSize: 5,
|
|
92
149
|
}
|
|
93
150
|
);
|
|
94
151
|
|
|
152
|
+
const pageInput = ref('1');
|
|
153
|
+
const rowRefs = useTemplateRef<HTMLElement[]>('rowRefs');
|
|
154
|
+
const headerRefs = useTemplateRef<HTMLElement[]>('headerRefs');
|
|
155
|
+
const rowHeights = ref<number[]>([]);
|
|
156
|
+
const columnWidths = ref<number[]>([]);
|
|
95
157
|
const currentPage = ref(1);
|
|
158
|
+
const isLoading = ref(false);
|
|
159
|
+
const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0});
|
|
160
|
+
const isAtLeastOneLoading = ref<boolean[]>([false]);
|
|
161
|
+
|
|
162
|
+
onMounted(() => {
|
|
163
|
+
refresh();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
watch([currentPage, () => props.data], async () => {
|
|
167
|
+
refresh();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
watch(() => currentPage.value, () => {
|
|
171
|
+
rowHeights.value = !rowRefs.value ? [] : rowRefs.value.map((el: HTMLElement) => el.offsetHeight);
|
|
172
|
+
columnWidths.value = !headerRefs.value ? [] : headerRefs.value.map((el: HTMLElement) => el.offsetWidth);
|
|
173
|
+
});
|
|
96
174
|
|
|
97
175
|
const totalPages = computed(() => {
|
|
98
|
-
return Math.ceil(
|
|
176
|
+
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
|
|
99
177
|
});
|
|
100
178
|
|
|
101
179
|
const dataPage = computed(() => {
|
|
102
|
-
|
|
103
|
-
const end = start + props.pageSize;
|
|
104
|
-
return props.data.slice(start, end);
|
|
180
|
+
return dataResult.value?.data;
|
|
105
181
|
});
|
|
106
182
|
|
|
107
183
|
function switchPage(p: number) {
|
|
108
184
|
currentPage.value = p;
|
|
185
|
+
pageInput.value = p.toString();
|
|
109
186
|
}
|
|
110
187
|
|
|
111
188
|
const emites = defineEmits([
|
|
112
189
|
'update:activeTab',
|
|
113
190
|
]);
|
|
114
191
|
|
|
192
|
+
function onPageInput(event: any) {
|
|
193
|
+
pageInput.value = event.target.innerText;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validatePageInput() {
|
|
197
|
+
const newPage = parseInt(pageInput.value) || 1;
|
|
198
|
+
const validPage = Math.max(1, Math.min(newPage, totalPages.value));
|
|
199
|
+
currentPage.value = validPage;
|
|
200
|
+
pageInput.value = validPage.toString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
watch(() => currentPage.value, (newPage) => {
|
|
204
|
+
pageInput.value = newPage.toString();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function onPageKeydown(event: any) {
|
|
208
|
+
// page input should accept only numbers, arrow keys and backspace
|
|
209
|
+
if (['Enter', 'Space'].includes(event.code) ||
|
|
210
|
+
(!['Backspace', 'ArrowRight', 'ArrowLeft'].includes(event.code)
|
|
211
|
+
&& isNaN(Number(String.fromCharCode(event.keyCode || 0))))) {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
if (event.code === 'Enter') {
|
|
214
|
+
validatePageInput();
|
|
215
|
+
event.target.blur();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function refresh() {
|
|
221
|
+
if (typeof props.data === 'function') {
|
|
222
|
+
isLoading.value = true;
|
|
223
|
+
const currentLoadingIndex = currentPage.value;
|
|
224
|
+
isAtLeastOneLoading.value[currentLoadingIndex] = true;
|
|
225
|
+
const result = await props.data({ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize });
|
|
226
|
+
isAtLeastOneLoading.value[currentLoadingIndex] = false;
|
|
227
|
+
if (isAtLeastOneLoading.value.every(v => v === false)) {
|
|
228
|
+
isLoading.value = false;
|
|
229
|
+
}
|
|
230
|
+
dataResult.value = result;
|
|
231
|
+
} else {
|
|
232
|
+
const start = (currentPage.value - 1) * props.pageSize;
|
|
233
|
+
const end = start + props.pageSize;
|
|
234
|
+
dataResult.value = { data: props.data.slice(start, end), total: props.data.length };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function refreshTable() {
|
|
239
|
+
if ( currentPage.value !== 1 ) {
|
|
240
|
+
currentPage.value = 1;
|
|
241
|
+
} else {
|
|
242
|
+
refresh();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
115
245
|
|
|
116
246
|
</script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
|
|
3
|
+
<textarea
|
|
4
|
+
ref="input"
|
|
5
|
+
class="bg-lightInputBackground border border-lightInputBorder text-lightInputText placeholder-lightInputPlaceholderText text-sm rounded-lg block w-full p-2.5 dark:bg-darkInputBackground dark:border-darkInputBorder dark:placeholder-darkInputPlaceholderText dark:text-darkInputText dark:border-darkInputBorder focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
|
|
6
|
+
:placeholder="placeholder"
|
|
7
|
+
:value="modelValue"
|
|
8
|
+
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
9
|
+
:readonly="readonly"
|
|
10
|
+
/>
|
|
11
|
+
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
|
|
16
|
+
import { ref } from 'vue';
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
modelValue: string,
|
|
20
|
+
readonly?: boolean,
|
|
21
|
+
placeholder?: string,
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
25
|
+
|
|
26
|
+
defineExpose({
|
|
27
|
+
focus: () => input.value?.focus(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
</script>
|
|
31
|
+
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
value="" class="sr-only peer"
|
|
6
6
|
:disabled="props.disabled"
|
|
7
7
|
:checked="props.modelValue"
|
|
8
|
-
@change="$emit('update:modelValue', $event.target.checked)"
|
|
8
|
+
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
|
9
9
|
>
|
|
10
|
-
<div class="border border-lightToggleBorderUnactive relative min-w-11 min-h-6 bg-lightToggleBgUnactive peer-focus:outline-none peer-focus:ring-4
|
|
10
|
+
<div class="afcl-toggle border border-lightToggleBorderUnactive relative min-w-11 min-h-6 bg-lightToggleBgUnactive peer-focus:outline-none peer-focus:ring-4
|
|
11
11
|
peer-focus:ring-lightToggleRing dark:peer-focus:ring-darkToggleRing rounded-full peer dark:bg-darkToggleBgUnactive
|
|
12
12
|
peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:top-[2px] peer-checked:border-none
|
|
13
13
|
peer-checked:after:border-lightToggleBorderActive after:content-[''] after:absolute after:top-[1px]
|
|
@@ -22,4 +22,4 @@ export { default as CountryFlag } from './CountryFlag.vue';
|
|
|
22
22
|
export { default as JsonViewer } from './JsonViewer.vue';
|
|
23
23
|
export { default as Toggle } from './Toggle.vue';
|
|
24
24
|
export { default as DatePicker } from './DatePicker.vue';
|
|
25
|
-
|
|
25
|
+
export { default as Textarea } from './Textarea.vue';
|
|
@@ -6,7 +6,7 @@ const modalStore = useModalStore();
|
|
|
6
6
|
|
|
7
7
|
<template>
|
|
8
8
|
<Teleport to="body">
|
|
9
|
-
<div v-if="modalStore.isOpened" class="bg-gray-900/50 dark:bg-gray-900/80 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-
|
|
9
|
+
<div v-if="modalStore.isOpened" class="bg-gray-900/50 dark:bg-gray-900/80 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-[100] justify-center items-center w-full md:inset-0 h-full max-h-full">
|
|
10
10
|
<div class="relative p-4 w-full max-w-md max-h-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 " >
|
|
11
11
|
<div class="afcl-confirmation-container relative bg-lightAcceptModalBackground rounded-lg shadow dark:bg-darkAcceptModalBackground dark:shadow-black">
|
|
12
12
|
<button type="button"@click="modalStore.togleModal" class="absolute top-3 end-2.5 text-lightAcceptModalCloseIcon bg-transparent hover:bg-lightAcceptModalCloseIconHoverBackground hover:text-lightAcceptModalCloseIconHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkAcceptModalCloseIcon dark:hover:bg-darkAcceptModalCloseIconHoverBackground dark:hover:text-darkAcceptModalCloseIconHover" >
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
step="1"
|
|
68
68
|
class="w-40"
|
|
69
69
|
placeholder="0"
|
|
70
|
+
:fullWidth="true"
|
|
70
71
|
:min="![undefined, null].includes(column.minValue) ? column.minValue : ''"
|
|
71
72
|
:max="![undefined, null].includes(column.maxValue) ? column.maxValue : ''"
|
|
72
73
|
:prefix="column.inputPrefix"
|
|
@@ -91,6 +92,7 @@
|
|
|
91
92
|
step="0.1"
|
|
92
93
|
class="w-40"
|
|
93
94
|
placeholder="0.0"
|
|
95
|
+
:fullWidth="true"
|
|
94
96
|
:min="![undefined, null].includes(column.minValue) ? column.minValue : ''"
|
|
95
97
|
:max="![undefined, null].includes(column.maxValue) ? column.maxValue : ''"
|
|
96
98
|
:prefix="column.inputPrefix"
|
|
@@ -99,28 +101,25 @@
|
|
|
99
101
|
@update:modelValue="$emit('update:modelValue', $event)"
|
|
100
102
|
:readonly="(column.editReadonly && source === 'edit') || readonly"
|
|
101
103
|
/>
|
|
102
|
-
<
|
|
104
|
+
<Textarea
|
|
103
105
|
v-else-if="['text', 'richtext'].includes(type || column.type)"
|
|
104
|
-
ref="input"
|
|
105
|
-
class="bg-lightInputBackground border border-lightInputBorder text-lightInputText placeholder-lightInputPlaceholderText text-sm rounded-lg block w-full p-2.5 dark:bg-darkInputBackground dark:border-darkInputBorder dark:placeholder-darkInputPlaceholderText dark:text-darkInputText dark:border-darkInputBorder focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
|
|
106
106
|
:placeholder="$t('Text')"
|
|
107
|
-
:
|
|
108
|
-
@
|
|
107
|
+
:modelValue="value"
|
|
108
|
+
@update:modelValue="$emit('update:modelValue', $event)"
|
|
109
109
|
:readonly="(column.editReadonly && source === 'edit') || readonly"
|
|
110
110
|
/>
|
|
111
|
-
<
|
|
111
|
+
<Textarea
|
|
112
112
|
v-else-if="['json'].includes(type || column.type)"
|
|
113
|
-
ref="input"
|
|
114
|
-
class="bg-lightInputBackground border border-lightInputBorder text-lightInputText placeholder-lightInputPlaceholderText text-sm rounded-lg block w-full p-2.5 dark:bg-darkInputBackground dark:border-darkInputBorder dark:placeholder-darkInputPlaceholderText dark:text-darkInputText dark:border-darkInputBorder focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
|
|
115
113
|
:placeholder="$t('Text')"
|
|
116
|
-
:
|
|
117
|
-
@
|
|
114
|
+
:modelValue="value"
|
|
115
|
+
@update:modelValue="$emit('update:modelValue', $event)"
|
|
118
116
|
/>
|
|
119
117
|
<Input
|
|
120
118
|
v-else
|
|
121
119
|
ref="input"
|
|
122
120
|
:type="!column.masked || unmasked[column.name] ? 'text' : 'password'"
|
|
123
121
|
class="w-full"
|
|
122
|
+
:fullWidth="true"
|
|
124
123
|
:placeholder="$t('Text')"
|
|
125
124
|
:prefix="column.inputPrefix"
|
|
126
125
|
:suffix="column.inputSuffix"
|
|
@@ -158,6 +157,7 @@
|
|
|
158
157
|
import Select from '@/afcl/Select.vue';
|
|
159
158
|
import Input from '@/afcl/Input.vue';
|
|
160
159
|
import Spinner from '@/afcl/Spinner.vue';
|
|
160
|
+
import Textarea from '@/afcl/Textarea.vue';
|
|
161
161
|
import { ref, inject } from 'vue';
|
|
162
162
|
import { getCustomComponent } from '@/utils';
|
|
163
163
|
import { useI18n } from 'vue-i18n';
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
const onSearchInput = inject('onSearchInput', {} as any);
|
|
192
192
|
const loadMoreOptions = inject('loadMoreOptions', (() => {}) as any);
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
const input = ref<HTMLInputElement | null>(null);
|
|
195
195
|
|
|
196
196
|
const getBooleanOptions = (column: any) => {
|
|
197
197
|
const options: Array<{ label: string; value: boolean | null }> = [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
@update:unmasked="$emit('update:unmasked', column.name)"
|
|
20
20
|
@update:inValidity="$emit('update:inValidity', { name: column.name, value: $event })"
|
|
21
21
|
@update:emptiness="$emit('update:emptiness', { name: column.name, value: $event })"
|
|
22
|
-
@delete="setCurrentValue(column.name, currentValues[column.name].filter((_, index) => index !== arrayItemIndex))"
|
|
22
|
+
@delete="setCurrentValue(column.name, currentValues[column.name].filter((_: any, index: any) => index !== arrayItemIndex))"
|
|
23
23
|
/>
|
|
24
24
|
</div>
|
|
25
25
|
<div class="flex items-center">
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
|
|
72
72
|
const emit = defineEmits(['update:unmasked', 'update:inValidity', 'update:emptiness', 'focus-last-input']);
|
|
73
73
|
|
|
74
|
-
const arrayItemRefs = ref([]);
|
|
74
|
+
const arrayItemRefs = ref<HTMLInputElement[]>([]);
|
|
75
75
|
|
|
76
76
|
async function addArrayItem() {
|
|
77
77
|
props.setCurrentValue(props.column.name, props.currentValues[props.column.name], props.currentValues[props.column.name].length);
|
|
@@ -57,15 +57,15 @@ const props = defineProps({
|
|
|
57
57
|
|
|
58
58
|
const emit = defineEmits(['update:valueStart', 'update:valueEnd']);
|
|
59
59
|
|
|
60
|
-
const minFormatted = computed(() => Math.floor(props.min));
|
|
61
|
-
const maxFormatted = computed(() => Math.ceil(props.max));
|
|
60
|
+
const minFormatted = computed(() => Math.floor(<number>props.min));
|
|
61
|
+
const maxFormatted = computed(() => Math.ceil(<number>props.max));
|
|
62
62
|
|
|
63
63
|
const isChanged = computed(() => {
|
|
64
64
|
return start.value && start.value !== minFormatted.value || end.value && end.value !== maxFormatted.value;
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
const start = ref(props.valueStart);
|
|
68
|
-
const end = ref(props.valueEnd);
|
|
67
|
+
const start = ref<string | number>(props.valueStart);
|
|
68
|
+
const end = ref<string | number>(props.valueEnd);
|
|
69
69
|
|
|
70
70
|
const sliderValue = ref([minFormatted.value, maxFormatted.value]);
|
|
71
71
|
|
|
@@ -118,7 +118,7 @@ const clear = () => {
|
|
|
118
118
|
setSliderValues('', '')
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function setSliderValues(start, end) {
|
|
121
|
+
function setSliderValues(start: any, end: any) {
|
|
122
122
|
sliderValue.value = [start || minFormatted.value, end || maxFormatted.value];
|
|
123
123
|
}
|
|
124
124
|
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="error" class="af-login-modal-error flex items-center p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
|
|
3
|
+
<svg class="flex-shrink-0 inline w-4 h-4 me-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
4
|
+
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
|
|
5
|
+
</svg>
|
|
6
|
+
<span class="sr-only">{{ $t('Info') }}</span>
|
|
7
|
+
<div>
|
|
8
|
+
{{ error }}
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup>
|
|
14
|
+
|
|
15
|
+
defineProps({
|
|
16
|
+
error: {
|
|
17
|
+
type: String,
|
|
18
|
+
default: null
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
@@ -108,9 +108,9 @@
|
|
|
108
108
|
:min="getFilterMinValue(c.name)"
|
|
109
109
|
:max="getFilterMaxValue(c.name)"
|
|
110
110
|
:valueStart="getFilterItem({ column: c, operator: 'gte' })"
|
|
111
|
-
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: $event
|
|
111
|
+
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
|
|
112
112
|
:valueEnd="getFilterItem({ column: c, operator: 'lte' })"
|
|
113
|
-
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: $event
|
|
113
|
+
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
|
|
114
114
|
/>
|
|
115
115
|
|
|
116
116
|
<div v-else-if="['integer', 'decimal', 'float'].includes(c.type)" class="flex gap-2">
|
|
@@ -118,14 +118,14 @@
|
|
|
118
118
|
type="number"
|
|
119
119
|
aria-describedby="helper-text-explanation"
|
|
120
120
|
:placeholder="$t('From')"
|
|
121
|
-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: $event
|
|
121
|
+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
|
|
122
122
|
:modelValue="getFilterItem({ column: c, operator: 'gte' })"
|
|
123
123
|
/>
|
|
124
124
|
<Input
|
|
125
125
|
type="number"
|
|
126
126
|
aria-describedby="helper-text-explanation"
|
|
127
127
|
:placeholder="$t('To')"
|
|
128
|
-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: $event
|
|
128
|
+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
|
|
129
129
|
:modelValue="getFilterItem({ column: c, operator: 'lte' })"
|
|
130
130
|
/>
|
|
131
131
|
</div>
|
|
@@ -269,7 +269,7 @@ const onSearchInput = computed(() => {
|
|
|
269
269
|
function setFilterItem({ column, operator, value }) {
|
|
270
270
|
|
|
271
271
|
const index = filtersStore.filters.findIndex(f => f.field === column.name && f.operator === operator);
|
|
272
|
-
if (value === undefined) {
|
|
272
|
+
if (value === undefined || value === '' || value === null) {
|
|
273
273
|
if (index !== -1) {
|
|
274
274
|
filtersStore.filters.splice(index, 1);
|
|
275
275
|
}
|
|
@@ -284,7 +284,8 @@ function setFilterItem({ column, operator, value }) {
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
function getFilterItem({ column, operator }) {
|
|
287
|
-
|
|
287
|
+
const filterValue = filtersStore.filters.find(f => f.field === column.name && f.operator === operator)?.value;
|
|
288
|
+
return filterValue !== undefined ? filterValue : '';
|
|
288
289
|
}
|
|
289
290
|
|
|
290
291
|
async function clear() {
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
import { ref, computed, watch, nextTick, type Ref } from 'vue';
|
|
71
71
|
import { useI18n } from 'vue-i18n';
|
|
72
72
|
import ColumnValueInputWrapper from "@/components/ColumnValueInputWrapper.vue";
|
|
73
|
+
import type { AdminForthResourceColumnInputCommon } from '@/types/Common';
|
|
73
74
|
|
|
74
75
|
const { t } = useI18n();
|
|
75
76
|
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
const customComponentsInValidity: Ref<Record<string, boolean>> = ref({});
|
|
90
91
|
const customComponentsEmptiness: Ref<Record<string, boolean>> = ref({});
|
|
91
92
|
const allColumnsHaveCustomComponent = computed(() => {
|
|
92
|
-
return props.group.columns.every(column => {
|
|
93
|
+
return props.group.columns.every((column: AdminForthResourceColumnInputCommon) => {
|
|
93
94
|
const componentKey = `${props.source}Row` as keyof typeof column.components;
|
|
94
95
|
return column.components?.[componentKey];
|
|
95
96
|
});
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
($route.name === item.path)
|
|
11
11
|
}"
|
|
12
12
|
>
|
|
13
|
-
<component v-if="item.icon" :is="getIcon(item.icon)" class="w-5 h-5 text-lightSidebarIcons dark:text-darkSidebarIcons transition duration-75 group-hover:text-lightSidebarIconsHover dark:group-hover:text-darkSidebarIconsHover" ></component>
|
|
13
|
+
<component v-if="item.icon" :is="getIcon(item.icon)" class="min-w-5 min-h-5 text-lightSidebarIcons dark:text-darkSidebarIcons transition duration-75 group-hover:text-lightSidebarIconsHover dark:group-hover:text-darkSidebarIconsHover" ></component>
|
|
14
14
|
<span class="text-ellipsis overflow-hidden ms-3">{{ item.label }}</span>
|
|
15
15
|
<span v-if="item.badge"
|
|
16
16
|
>
|
|
17
17
|
|
|
18
18
|
<Tooltip v-if="item.badgeTooltip">
|
|
19
|
-
<div class="inline-flex items-center justify-center h-3 py-3 px-1 ms-3 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
|
|
19
|
+
<div class="af-badge inline-flex items-center justify-center h-3 py-3 px-1 ms-3 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
|
|
20
20
|
fill-lightAnnouncementText dark:fill-darkAccent text-lightAnnouncementText dark:text-darkAccent min-w-[1.5rem] max-w-[3rem]">{{ item.badge }}</div>
|
|
21
21
|
|
|
22
22
|
<template #tooltip>
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</template>
|
|
25
25
|
</Tooltip>
|
|
26
26
|
<template v-else>
|
|
27
|
-
<div class="inline-flex items-center justify-center h-3 py-3 px-1 ms-3 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
|
|
27
|
+
<div class="af-badge inline-flex items-center justify-center h-3 py-3 px-1 ms-3 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
|
|
28
28
|
fill-lightAnnouncementText dark:fill-darkAccent text-lightAnnouncementText dark:text-darkAccent min-w-[1.5rem] max-w-[3rem]">{{ item.badge }}</div>
|
|
29
29
|
</template>
|
|
30
30
|
|