@opengis/cms 0.0.21 → 0.0.22
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/package.json +2 -2
- package/src/App.css +52 -0
- package/src/App.vue +62 -0
- package/src/assets/image.png +0 -0
- package/src/assets/main.css +3 -0
- package/src/assets/tailwind-3.4.17.js +113 -0
- package/src/components/LanguageSwitcher.vue +73 -0
- package/src/components/SettingsCard.vue +40 -0
- package/src/components/builder/CreateForm.vue +128 -0
- package/src/components/builder/formTypeSchema.js +145 -0
- package/src/components/builder/tabs/index.ts +9 -0
- package/src/components/builder/tabs/vs-builder-edit.vue +133 -0
- package/src/components/builder/tabs/vs-builder-monaco.vue +29 -0
- package/src/components/builder/tabs/vs-builder-preview.vue +39 -0
- package/src/components/builder/vs-builder-datatable-controls.vue +138 -0
- package/src/components/builder/vs-builder-datatable-form.vue +80 -0
- package/src/components/builder/vs-builder-datatable.vue +191 -0
- package/src/components/builder/vs-builder-list-item.vue +110 -0
- package/src/components/collections/CollectionsBreadcrumb.vue +52 -0
- package/src/components/collections/CollectionsGrid.vue +176 -0
- package/src/components/collections/ContentBlock.vue +75 -0
- package/src/components/collections/formWrapper.vue +156 -0
- package/src/components/dashboard/ContentItem.vue +82 -0
- package/src/components/dashboard/DashboardHeader.vue +33 -0
- package/src/components/dashboard/QuickActions.vue +84 -0
- package/src/components/dashboard/RecentContent.vue +54 -0
- package/src/components/dashboard/StatCard.vue +63 -0
- package/src/components/dashboard/StatsGrid.vue +28 -0
- package/src/components/form-components/MonacoEditor.vue +104 -0
- package/src/components/form-components/VsFormMeta.vue +40 -0
- package/src/components/form-components/VsFormTags.vue +150 -0
- package/src/components/form-components/custom-datatable/vs-form-custom-datatable-add.vue +84 -0
- package/src/components/form-components/custom-datatable/vs-form-custom-datatable-controls.vue +106 -0
- package/src/components/form-components/index.js +23 -0
- package/src/components/form-components/reference/vs-form-reference-add.vue +92 -0
- package/src/components/form-components/reference/vs-form-reference-controls.vue +101 -0
- package/src/components/form-components/reference-list/referenceOptionList.js +78 -0
- package/src/components/form-components/reference-list/vs-form-reference-add.vue +145 -0
- package/src/components/form-components/reference-list/vs-form-reference-choce.vue +39 -0
- package/src/components/form-components/reference-list/vs-form-reference-controls.vue +110 -0
- package/src/components/form-components/reference-skeleton/about-skeleton.vue +37 -0
- package/src/components/form-components/reference-skeleton/banner-skeleton.vue +29 -0
- package/src/components/form-components/reference-skeleton/body-skeleton.vue +56 -0
- package/src/components/form-components/reference-skeleton/cards-skeleton.vue +47 -0
- package/src/components/form-components/reference-skeleton/documents-skeleton.vue +64 -0
- package/src/components/form-components/reference-skeleton/faq-skeleton.vue +64 -0
- package/src/components/form-components/reference-skeleton/form-skeleton.vue +41 -0
- package/src/components/form-components/reference-skeleton/index.js +36 -0
- package/src/components/form-components/reference-skeleton/infoLine-skeleton.vue +37 -0
- package/src/components/form-components/reference-skeleton/news-skeleton.vue +54 -0
- package/src/components/form-components/reference-skeleton/slider-skeleton.vue +41 -0
- package/src/components/form-components/reference-skeleton/tabs-skeleton.vue +40 -0
- package/src/components/form-components/reference-skeleton/team-skeleton.vue +103 -0
- package/src/components/form-components/reference-skeleton/usefulLinks-skeleton.vue +52 -0
- package/src/components/form-components/reference-skeleton/video-skeleton.vue +36 -0
- package/src/components/form-components/testReferenceTypes.js +773 -0
- package/src/components/form-components/vs-form-color-picker.vue +29 -0
- package/src/components/form-components/vs-form-custom-datatable.vue +214 -0
- package/src/components/form-components/vs-form-integer.vue +86 -0
- package/src/components/form-components/vs-form-key-value.vue +201 -0
- package/src/components/form-components/vs-form-marcdown-md.vue +3 -0
- package/src/components/form-components/vs-form-media-select.vue +780 -0
- package/src/components/form-components/vs-form-reference-list.vue +97 -0
- package/src/components/form-components/vs-form-reference.vue +59 -0
- package/src/components/form-components/vs-form-relation.vue +30 -0
- package/src/components/form-components/vs-form-reletion-link.vue +34 -0
- package/src/components/form-components/vs-form-select-collection.vue +0 -0
- package/src/components/form-components/vs-form-slug.vue +72 -0
- package/src/components/form-components/vs-form-tiptap.vue +7 -0
- package/src/components/form-components/vs-richtext-md.vue +3 -0
- package/src/components/icons/BellIcon.vue +17 -0
- package/src/components/icons/GlobeIcon.vue +18 -0
- package/src/components/icons/KeyIcon.vue +20 -0
- package/src/components/icons/PaletteIcon.vue +22 -0
- package/src/components/icons/SettingsIcon.vue +19 -0
- package/src/components/icons/ShieldIcon.vue +18 -0
- package/src/components/icons/UsersIcon.vue +19 -0
- package/src/components/icons/icon-chevron-right.vue +16 -0
- package/src/components/icons/icon-drag.vue +20 -0
- package/src/components/icons/icon-file-text.vue +21 -0
- package/src/components/icons/icon-folder.vue +18 -0
- package/src/components/icons/icon-grid.vue +17 -0
- package/src/components/icons/icon-group.vue +19 -0
- package/src/components/icons/icon-home.vue +16 -0
- package/src/components/icons/icon-image.vue +18 -0
- package/src/components/icons/icon-list.vue +20 -0
- package/src/components/icons/icon-more.vue +17 -0
- package/src/components/icons/icon-plus.vue +17 -0
- package/src/components/icons-types/icon-array.vue +22 -0
- package/src/components/icons-types/icon-boolean.vue +18 -0
- package/src/components/icons-types/icon-datalist.vue +22 -0
- package/src/components/icons-types/icon-date.vue +20 -0
- package/src/components/icons-types/icon-datetime.vue +20 -0
- package/src/components/icons-types/icon-file.vue +21 -0
- package/src/components/icons-types/icon-gallery.vue +18 -0
- package/src/components/icons-types/icon-image.vue +19 -0
- package/src/components/icons-types/icon-integer.vue +20 -0
- package/src/components/icons-types/icon-merkdown.vue +18 -0
- package/src/components/icons-types/icon-multiselect.vue +22 -0
- package/src/components/icons-types/icon-number.vue +20 -0
- package/src/components/icons-types/icon-radio.vue +22 -0
- package/src/components/icons-types/icon-reference-list.vue +22 -0
- package/src/components/icons-types/icon-reference.vue +20 -0
- package/src/components/icons-types/icon-relation.vue +22 -0
- package/src/components/icons-types/icon-richtext.vue +18 -0
- package/src/components/icons-types/icon-select.vue +22 -0
- package/src/components/icons-types/icon-slug.vue +19 -0
- package/src/components/icons-types/icon-text.vue +19 -0
- package/src/components/icons-types/index.js +43 -0
- package/src/components/layout/Layout.vue +67 -0
- package/src/components/layout/Sidebar.vue +128 -0
- package/src/components/media/FileUploadProgress.vue +29 -0
- package/src/components/media/MediaBreadcrumb.vue +42 -0
- package/src/components/media/MediaCreateFolder.vue +59 -0
- package/src/components/media/MediaFileInfo.vue +148 -0
- package/src/components/media/MediaGrid.vue +148 -0
- package/src/components/media/MediaList.vue +148 -0
- package/src/components/media/MediaViewControls.vue +38 -0
- package/src/components/media/TypeTag.vue +23 -0
- package/src/components/menu/AddNewItemInTree.vue +75 -0
- package/src/components/menu/MenuBody.vue +149 -0
- package/src/components/menu/MenuItem.vue +73 -0
- package/src/components/menu/MenuList.vue +101 -0
- package/src/components/referencec/index.ts +7 -0
- package/src/components/referencec/vs-reference-faq.vue +61 -0
- package/src/components/referencec/vs-reference-user-card.vue +40 -0
- package/src/components/settings/NotificationSettings.vue +32 -0
- package/src/components/settings/SettingsTable.vue +50 -0
- package/src/components/settings/SettingsTitle.vue +33 -0
- package/src/components/settings/SettingsToggleItem.vue +25 -0
- package/src/components/sidebar/DropdownMenu.vue +34 -0
- package/src/components/sidebar/SettingsSidebar.vue +121 -0
- package/src/components/sidebar/SidebarFooter.vue +52 -0
- package/src/components/sidebar/SidebarHeader.vue +57 -0
- package/src/components/sidebar/SidebarMenu.vue +78 -0
- package/src/components/ui/EmptyData.vue +76 -0
- package/src/components/ui/UniversalTable.vue +310 -0
- package/src/components/ui/UniversalTableFilters.vue +0 -0
- package/src/components/ui/UniversalTablePagination.vue +118 -0
- package/src/components/ui/VsPreview.vue +75 -0
- package/src/composables/useCollectionView.ts +21 -0
- package/src/composables/useDebounce.ts +26 -0
- package/src/composables/useMonaco.ts +28 -0
- package/src/composables/useTheme.ts +40 -0
- package/src/content/test-slug/metadata.json +1 -0
- package/src/i18n.ts +75 -0
- package/src/index.css +3 -0
- package/src/locales/en.json +778 -0
- package/src/locales/uk.json +797 -0
- package/src/main.ts +41 -0
- package/src/pages/Dashboard.vue +168 -0
- package/src/pages/EmailPage.vue +183 -0
- package/src/pages/FeedbackPage.vue +232 -0
- package/src/pages/MediaPage.vue +372 -0
- package/src/pages/TagsPage.vue +207 -0
- package/src/pages/builder/BuilderPage.vue +195 -0
- package/src/pages/builder/EditCollectionPage.vue +163 -0
- package/src/pages/collections/ArticlesPage.vue +385 -0
- package/src/pages/collections/CollectionsPage.vue +146 -0
- package/src/pages/collections/SingletonsPage.vue +119 -0
- package/src/pages/collections/contentForm.vue +484 -0
- package/src/pages/collections/schema/seo.ts +27 -0
- package/src/pages/menu/MenuAddPage.vue +123 -0
- package/src/pages/menu/MenuItemPage.vue +183 -0
- package/src/pages/menu/MenuPage.vue +133 -0
- package/src/pages/settings/ApiKeys.vue +75 -0
- package/src/pages/settings/Appearance.vue +80 -0
- package/src/pages/settings/Logs.vue +260 -0
- package/src/pages/settings/PermissionsPage.vue +237 -0
- package/src/pages/settings/Settings.vue +186 -0
- package/src/pages/settings/Users.vue +109 -0
- package/src/pages/settings/general.vue +154 -0
- package/src/pages/settings/generalScheme.js +132 -0
- package/src/pages/users/AddUser.vue +106 -0
- package/src/pages/users/UsersPage.vue +98 -0
- package/src/props/builder.ts +67 -0
- package/src/props/content.ts +56 -0
- package/src/props/media.ts +63 -0
- package/src/router/index.ts +181 -0
- package/src/types/fastify-auth.d.ts +4 -0
- package/src/utils/getField.js +270 -0
- package/src/utils/translit.js +19 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full max-w-7xl mx-auto space-y-6 h-full">
|
|
3
|
+
<div class="flex justify-between items-center mb-8">
|
|
4
|
+
<div>
|
|
5
|
+
<div class="flex items-center gap-2">
|
|
6
|
+
<h1 class="text-3xl font-bold text-slate-800 dark:text-slate-100 mb-2">{{ $t("media.title") }}</h1>
|
|
7
|
+
<a :href="`https://cms.opengis.info/${locale}/guides/view/#5-медіа`" target="_blank" :title="$t('guide.media')">
|
|
8
|
+
<HelpCircle class="w-5 h-5" />
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="text-slate-600 dark:text-slate-300">{{ $t("media.description") }}</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<MediaViewControls
|
|
15
|
+
v-model="viewMode"
|
|
16
|
+
@create-folder="isCreateFolderOpen = true"
|
|
17
|
+
@file-change="handleFileChange"
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="h-[calc(100%-100px)]">
|
|
21
|
+
<div class="flex gap-6 h-full">
|
|
22
|
+
<div class="space-y-4 flex-1">
|
|
23
|
+
<div class="flex items-center justify-between">
|
|
24
|
+
<MediaBreadcrumb
|
|
25
|
+
:current-path="currentPath"
|
|
26
|
+
@navigate="navigateToFolder"
|
|
27
|
+
@navigate-back="navigateBack"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="relative z-[1] rounded-xl text-card-foreground shadow-lg border-0 bg-white dark:bg-slate-800 backdrop-blur-sm mb-6 p-4">
|
|
31
|
+
<div class="flex items-center justify-between gap-4">
|
|
32
|
+
<div class="flex items-center gap-4 flex-1">
|
|
33
|
+
<div class="relative flex-1 max-w-md">
|
|
34
|
+
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500" />
|
|
35
|
+
<input
|
|
36
|
+
type="text"
|
|
37
|
+
:placeholder="$t('media.search')"
|
|
38
|
+
class="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500"
|
|
39
|
+
v-model="searchQuery"
|
|
40
|
+
>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="flex items-center gap-2 relative">
|
|
43
|
+
<span class="text-xs font-bold">{{ $t("media.sortBy.title") + ': ' }}</span>
|
|
44
|
+
<button
|
|
45
|
+
class="relative z-[1000] inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors border shadow-sm h-8 rounded-md px-3 text-xs bg-white border-slate-300 text-slate-700
|
|
46
|
+
disabled:pointer-events-none disabled:opacity-50
|
|
47
|
+
hover:text-accent-foreground hover:bg-slate-50
|
|
48
|
+
dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-600"
|
|
49
|
+
@click="isSortByOpen = !isSortByOpen"
|
|
50
|
+
>
|
|
51
|
+
<ChevronDown class="w-4 h-4" />
|
|
52
|
+
<span class="text-xs font-bold">{{ sortByOptions.get(sortBy) }}</span>
|
|
53
|
+
<div v-if="isSortByOpen" class="w-full absolute top-full left-1/2 transform -translate-x-1/2 mt-2 z-50 bg-white dark:bg-slate-700 flex flex-col items-start border border-slate-200 dark:border-slate-600">
|
|
54
|
+
<span class="text-left text-xs font-bold w-full p-2 hover:bg-slate-100 dark:hover:bg-slate-600 border-b border-slate-200 dark:border-slate-600" @click.stop="sorting('name')">{{ $t("media.sortBy.name") }}</span>
|
|
55
|
+
<span class="text-left text-xs font-bold w-full p-2 hover:bg-slate-100 dark:hover:bg-slate-600" @click.stop="sorting('filesize')">{{ $t("media.sortBy.filesize") }}</span>
|
|
56
|
+
</div>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<VsFilter
|
|
61
|
+
:schema="filterScheme"
|
|
62
|
+
view="inline"
|
|
63
|
+
:value="filterValue"
|
|
64
|
+
@change="handleChange"
|
|
65
|
+
/>
|
|
66
|
+
<div class="flex items-center gap-2">
|
|
67
|
+
<button
|
|
68
|
+
class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs rounded-md"
|
|
69
|
+
:class="{
|
|
70
|
+
'shadow bg-blue-600 hover:bg-blue-700 text-white': viewMode === 'grid',
|
|
71
|
+
'border shadow-sm hover:text-accent-foreground bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600': viewMode === 'list',
|
|
72
|
+
}"
|
|
73
|
+
@click="viewMode = 'grid'"
|
|
74
|
+
>
|
|
75
|
+
<Grid class="w-4 h-4" />
|
|
76
|
+
</button>
|
|
77
|
+
<button
|
|
78
|
+
class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs rounded-md"
|
|
79
|
+
:class="{
|
|
80
|
+
'shadow bg-blue-600 hover:bg-blue-700 text-white': viewMode === 'list',
|
|
81
|
+
'border shadow-sm hover:text-accent-foreground bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600': viewMode === 'grid',
|
|
82
|
+
}"
|
|
83
|
+
@click="viewMode = 'list'"
|
|
84
|
+
>
|
|
85
|
+
<List class="w-4 h-4" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<MediaGrid
|
|
92
|
+
v-if="viewMode === 'grid' && files.length > 0"
|
|
93
|
+
:files="files"
|
|
94
|
+
:selected-file="selectedFile"
|
|
95
|
+
:is-delete-button="true"
|
|
96
|
+
@file-click="handleFileClick"
|
|
97
|
+
@delete-file="handleDeleteFile"
|
|
98
|
+
@delete-folder="handleDeleteFolder"
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
<MediaList
|
|
102
|
+
v-else-if="files.length > 0"
|
|
103
|
+
:files="files"
|
|
104
|
+
:selected-file="selectedFile"
|
|
105
|
+
@file-click="handleFileClick"
|
|
106
|
+
@delete-folder="handleDeleteFolder"
|
|
107
|
+
:is-delete-button="true"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<MediaFileInfo
|
|
112
|
+
:file="selectedFile"
|
|
113
|
+
@delete-file="handleDeleteFile"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
<MediaCreateFolder
|
|
120
|
+
v-model="isCreateFolderOpen"
|
|
121
|
+
@create="handleCreateFolder"
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
<FileUploadProgress :uploads="uploads" />
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<script setup>
|
|
129
|
+
import { ref, onMounted, watch } from "vue";
|
|
130
|
+
import { useRouter, useRoute } from "vue-router";
|
|
131
|
+
import MediaBreadcrumb from "../components/media/MediaBreadcrumb.vue";
|
|
132
|
+
import MediaViewControls from "../components/media/MediaViewControls.vue";
|
|
133
|
+
import MediaGrid from "../components/media/MediaGrid.vue";
|
|
134
|
+
import MediaList from "../components/media/MediaList.vue";
|
|
135
|
+
import MediaFileInfo from "../components/media/MediaFileInfo.vue";
|
|
136
|
+
import MediaCreateFolder from "../components/media/MediaCreateFolder.vue";
|
|
137
|
+
import FileUploadProgress from "../components/media/FileUploadProgress.vue";
|
|
138
|
+
import { Grid, List, Search, ChevronDown, HelpCircle } from "lucide-vue-next";
|
|
139
|
+
import { useI18n } from "vue-i18n";
|
|
140
|
+
import VsFilter from '@opengis/filter';
|
|
141
|
+
import { useDebounce } from "../composables/useDebounce";
|
|
142
|
+
|
|
143
|
+
const router = useRouter();
|
|
144
|
+
const route = useRoute();
|
|
145
|
+
const { t, locale } = useI18n();
|
|
146
|
+
|
|
147
|
+
// State
|
|
148
|
+
const viewMode = ref("grid");
|
|
149
|
+
const currentPath = ref([]);
|
|
150
|
+
const selectedFile = ref(null);
|
|
151
|
+
const files = ref([]);
|
|
152
|
+
const isCreateFolderOpen = ref(false);
|
|
153
|
+
const pathHistory = ref([]);
|
|
154
|
+
const uploads = ref([]); // [{id, name, progress, status}]
|
|
155
|
+
const searchQuery = ref("");
|
|
156
|
+
const isSortByOpen = ref(false);
|
|
157
|
+
const sortBy = ref("name");
|
|
158
|
+
const filterScheme = ref([
|
|
159
|
+
{
|
|
160
|
+
name: "date",
|
|
161
|
+
type: "date",
|
|
162
|
+
label: "Дата завантаження"
|
|
163
|
+
}
|
|
164
|
+
]);
|
|
165
|
+
const filterValue = ref( route.query.filter ? route.query.filter.split('|').map(el=>el.split('=')).reduce((f,[k,v])=>Object.assign(f,{[k]:v}),{}) : {} );
|
|
166
|
+
|
|
167
|
+
const sortByOptions = new Map([
|
|
168
|
+
["name", t("media.sortBy.name")],
|
|
169
|
+
["filesize", t("media.sortBy.filesize")],
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// Methods
|
|
173
|
+
const sorting = (sort) => {
|
|
174
|
+
sortBy.value = sort;
|
|
175
|
+
isSortByOpen.value = false;
|
|
176
|
+
|
|
177
|
+
files.value = files.value.sort((a, b) => {
|
|
178
|
+
if(a?.type === "dir") return -1;
|
|
179
|
+
|
|
180
|
+
if (sort === "name") {
|
|
181
|
+
return a?.name?.localeCompare(b?.name);
|
|
182
|
+
} else if (sort === "filesize") {
|
|
183
|
+
return b?.filesize - a?.filesize;
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const navigateToFolder = (path) => {
|
|
189
|
+
currentPath.value = path;
|
|
190
|
+
pathHistory.value = [...pathHistory.value, path];
|
|
191
|
+
selectedFile.value = null;
|
|
192
|
+
fetchFiles();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const navigateBack = () => {
|
|
196
|
+
if (pathHistory.value.length > 1) {
|
|
197
|
+
pathHistory.value.pop(); // Remove current path
|
|
198
|
+
currentPath.value = pathHistory.value[pathHistory.value.length - 1];
|
|
199
|
+
selectedFile.value = null;
|
|
200
|
+
fetchFiles();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleFileClick = (file) => {
|
|
205
|
+
if (file.type === "dir") {
|
|
206
|
+
navigateToFolder([...currentPath.value, file.name]);
|
|
207
|
+
} else {
|
|
208
|
+
selectedFile.value = file.id === selectedFile.value?.id ? null : file;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleDeleteFile = async(file) => {
|
|
213
|
+
if(file?.id) {
|
|
214
|
+
try {
|
|
215
|
+
await fetch(`/api/cms-media/${file.id}`, { method: 'DELETE' });
|
|
216
|
+
await fetchFiles();
|
|
217
|
+
selectedFile.value = null;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error("Delete error:", err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const searchFiles = async () => {
|
|
225
|
+
const response = await fetch("/api/cms-media?subdir=" + currentPath.value.join("/") + "&search=" + searchQuery.value);
|
|
226
|
+
const data = await response.json();
|
|
227
|
+
files.value = data.data ? data.data : [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const handleDeleteFolder = async(folder) => {
|
|
231
|
+
let deletePath = "";
|
|
232
|
+
currentPath.value.forEach((path) => {
|
|
233
|
+
let parsedPath = path.replace(/ /g, "+");
|
|
234
|
+
|
|
235
|
+
deletePath += parsedPath + "/";
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
deletePath += folder.name.replace(/ /g, "+");
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await fetch(`/api/cms-media?subdir=${deletePath}`, {
|
|
242
|
+
method: 'DELETE',
|
|
243
|
+
headers: {
|
|
244
|
+
"X-Skip-Session": "true",
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
await fetchFiles();
|
|
248
|
+
selectedFile.value = null;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error("Delete error:", err);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const handleFileChange = async (e) => {
|
|
255
|
+
const uploadedFiles = Array.from(e.target.files);
|
|
256
|
+
if (!uploadedFiles.length) return;
|
|
257
|
+
|
|
258
|
+
uploads.value = uploadedFiles.map((file, idx) => ({
|
|
259
|
+
id: idx + "-" + Date.now(),
|
|
260
|
+
name: file.name,
|
|
261
|
+
progress: 0,
|
|
262
|
+
status: "uploading", // uploading | success | error
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
const path = currentPath.value.join("/");
|
|
266
|
+
|
|
267
|
+
for (let idx = 0; idx < uploadedFiles.length; idx++) {
|
|
268
|
+
const file = uploadedFiles[idx];
|
|
269
|
+
const formData = new FormData();
|
|
270
|
+
formData.append("file", file);
|
|
271
|
+
try {
|
|
272
|
+
const response = await fetch(`/api/cms-media/upload?subdir=${path}`, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
"X-Skip-Session": "true",
|
|
276
|
+
},
|
|
277
|
+
body: formData,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Simulate upload progress since fetch doesn't support it natively
|
|
281
|
+
for (let progress = 0; progress <= 100; progress += 10) {
|
|
282
|
+
uploads.value[idx].progress = progress;
|
|
283
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setTimeout(() => {
|
|
287
|
+
uploads.value[idx].status = "success";
|
|
288
|
+
uploads.value[idx].progress = 100;
|
|
289
|
+
}, 1000);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
uploads.value[idx].status = "error";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await fetchFiles();
|
|
296
|
+
|
|
297
|
+
// Автоматично ховаємо через 3 сек після завершення всіх
|
|
298
|
+
setTimeout(() => {
|
|
299
|
+
uploads.value = [];
|
|
300
|
+
}, 3000);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const handleCreateFolder = async (formData) => {
|
|
304
|
+
try {
|
|
305
|
+
const path = currentPath.value.join("/");
|
|
306
|
+
await fetch(`/api/cms-media/upload?subdir=${path}/${formData.name}`, { method: 'POST' });
|
|
307
|
+
isCreateFolderOpen.value = false;
|
|
308
|
+
await fetchFiles();
|
|
309
|
+
} catch (err) {
|
|
310
|
+
error.value = "Failed to create folder";
|
|
311
|
+
} finally {
|
|
312
|
+
uploads.value = [];
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const fetchFiles = async () => {
|
|
317
|
+
try {
|
|
318
|
+
const path = currentPath.value.join("/");
|
|
319
|
+
let url = `/api/cms-media?subdir=${path}`;
|
|
320
|
+
if (filterValue.value && Object.keys(filterValue.value).length > 0) {
|
|
321
|
+
url += `&filter=${Object.entries(filterValue.value).map(([key, val]) => `${key}=${val}`)}`;
|
|
322
|
+
}
|
|
323
|
+
const response = await fetch(url);
|
|
324
|
+
const data = await response.json();
|
|
325
|
+
files.value = data.data ? data.data : [];
|
|
326
|
+
|
|
327
|
+
sorting("name");
|
|
328
|
+
} catch (err) {
|
|
329
|
+
error.value = "Failed to fetch files";
|
|
330
|
+
console.error("Fetch error:", err);
|
|
331
|
+
files.value = [];
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const handleChange = async (filters) => {
|
|
336
|
+
const filterStr = Object.entries(filters?.data)
|
|
337
|
+
.filter(([, v]) => v != null)
|
|
338
|
+
.map(([key, val]) => `${key}=${val}`)
|
|
339
|
+
.join('|');
|
|
340
|
+
|
|
341
|
+
filterValue.value = filters?.data;
|
|
342
|
+
|
|
343
|
+
if (filterValue.value ==='no-url') {
|
|
344
|
+
} else {
|
|
345
|
+
router.replace({
|
|
346
|
+
query: { filter:filterStr }
|
|
347
|
+
})
|
|
348
|
+
};
|
|
349
|
+
await fetchFiles();
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
watch(currentPath, (n) => {
|
|
353
|
+
if (n.length > 0) {
|
|
354
|
+
router.push({ query: { path: '/' + n.join("/") } });
|
|
355
|
+
} else {
|
|
356
|
+
router.push({ query: { path: undefined } });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const searchFilesDebounced = useDebounce(searchQuery.value, 300);
|
|
361
|
+
|
|
362
|
+
watch(searchQuery, useDebounce((val) => {
|
|
363
|
+
searchFiles();
|
|
364
|
+
}, 500));
|
|
365
|
+
|
|
366
|
+
onMounted(() => {
|
|
367
|
+
pathHistory.value = [[]];
|
|
368
|
+
fetchFiles();
|
|
369
|
+
});
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<style></style>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-6 max-w-7xl mx-auto">
|
|
3
|
+
<div class="flex items-center justify-between mb-8">
|
|
4
|
+
<div>
|
|
5
|
+
<div class="flex items-center gap-2">
|
|
6
|
+
<h1 class="text-3xl font-bold text-slate-800 dark:text-slate-100 mb-2">{{ $t("tags.title") }}</h1>
|
|
7
|
+
<a :href="`https://cms.opengis.info/${locale}`" target="_blank" :title="$t('guide.general')">
|
|
8
|
+
<HelpCircle class="w-5 h-5" />
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="text-slate-600 dark:text-slate-300">{{ $t("tags.description") }}</p>
|
|
12
|
+
</div>
|
|
13
|
+
<button
|
|
14
|
+
v-if="tags?.rows?.length > 0"
|
|
15
|
+
@click="isOpen = true"
|
|
16
|
+
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium h-9 px-4 py-2 bg-blue-600 text-white shadow-md transition-all duration-200 transform
|
|
17
|
+
hover:bg-blue-700 hover:shadow-lg hover:scale-105
|
|
18
|
+
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
|
19
|
+
disabled:pointer-events-none disabled:opacity-50"
|
|
20
|
+
>
|
|
21
|
+
<Plus class="w-4 h-4 mr-2" />
|
|
22
|
+
{{ $t("tags.createTag") }}
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="rounded-xl text-card-foreground shadow-lg border-0 bg-white dark:bg-slate-800 backdrop-blur-sm overflow-hidden">
|
|
26
|
+
<EmptyData
|
|
27
|
+
v-if="!tags || tags?.rows?.length === 0"
|
|
28
|
+
:title="t('tags.emptyTitle')"
|
|
29
|
+
:description="t('tags.emptyDescription')"
|
|
30
|
+
:button-text="t('tags.emptyButton')"
|
|
31
|
+
:show-button="true"
|
|
32
|
+
@action="isOpen = true"
|
|
33
|
+
/>
|
|
34
|
+
<UniversalTable
|
|
35
|
+
v-if="tags?.rows?.length > 0"
|
|
36
|
+
:columns="columns"
|
|
37
|
+
:rows="tags.rows"
|
|
38
|
+
:on-edit="handleEditTag"
|
|
39
|
+
@delete="handleDeleteTag"
|
|
40
|
+
/>
|
|
41
|
+
<UniversalTablePagination
|
|
42
|
+
v-if="tags?.total > limit"
|
|
43
|
+
:total="tags?.total"
|
|
44
|
+
:count="count"
|
|
45
|
+
v-model:page="page"
|
|
46
|
+
:limit="limit"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<VsModal
|
|
50
|
+
teleport="#modal"
|
|
51
|
+
:visible="isOpen"
|
|
52
|
+
:title="t( isEdit ? 'tags.editTag' : 'tags.createTag')"
|
|
53
|
+
@close="closeForm()"
|
|
54
|
+
>
|
|
55
|
+
<VForm v-model="formValue" :schema="schema" v-model:form="form" />
|
|
56
|
+
<template #footer>
|
|
57
|
+
<div class="flex justify-end p-4 gap-2 border-t w-full">
|
|
58
|
+
<button @click="closeForm()" class="bg-gray-500 text-white px-4 py-2 rounded-md">{{ t('common.actions.cancel') }}</button>
|
|
59
|
+
<button @click="handleCreateTag" class="bg-blue-500 text-white px-4 py-2 rounded-md">{{ t('common.actions.save') }}</button>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
</VsModal>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<script setup>
|
|
67
|
+
import { ref, onMounted, computed, watch } from 'vue';
|
|
68
|
+
import UniversalTable from '@/components/ui/UniversalTable.vue';
|
|
69
|
+
import UniversalTablePagination from '@/components/ui/UniversalTablePagination.vue';
|
|
70
|
+
import { useI18n } from 'vue-i18n';
|
|
71
|
+
import { Plus, HelpCircle } from 'lucide-vue-next';
|
|
72
|
+
import EmptyData from '@/components/ui/EmptyData.vue';
|
|
73
|
+
import { VForm } from "@opengis/form";
|
|
74
|
+
// import { VForm } from '../../../form/src/index'
|
|
75
|
+
import { VsModal } from "@opengis/core";
|
|
76
|
+
import { notify } from '@opengis/core';
|
|
77
|
+
const { t, locale } = useI18n();
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
const tags = ref([]);
|
|
81
|
+
const isOpen = ref(false);
|
|
82
|
+
const formValue = ref({
|
|
83
|
+
color: "#000000",
|
|
84
|
+
});
|
|
85
|
+
const form = ref({});
|
|
86
|
+
const isEdit = ref(false);
|
|
87
|
+
const limit = ref(14);
|
|
88
|
+
const page = ref(1);
|
|
89
|
+
const count = computed(() => {
|
|
90
|
+
const maxCount = page.value * limit.value;
|
|
91
|
+
return maxCount > tags.value.total ? tags.value.total : maxCount;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const columns = [
|
|
95
|
+
{ name: "value", title: t("tags.form.name"), type: "text" },
|
|
96
|
+
{ name: "count_data", title: t("tags.form.count_data"), type: "text" },
|
|
97
|
+
{ name: "color", title: t("tags.form.color"), type: "color" },
|
|
98
|
+
{ name: "created_at", title: t("tags.form.created_at"), type: "date" },
|
|
99
|
+
{ name: "description", title: t("tags.form.description"), type:"text" },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const schema = {
|
|
103
|
+
slug:{
|
|
104
|
+
label: t("tags.form.slug"),
|
|
105
|
+
type: "slug",
|
|
106
|
+
parent: "value",
|
|
107
|
+
validators: ["required"],
|
|
108
|
+
},
|
|
109
|
+
value:{
|
|
110
|
+
label: t("tags.form.name"),
|
|
111
|
+
type: "text",
|
|
112
|
+
validators: ["required"],
|
|
113
|
+
},
|
|
114
|
+
'value:en':{
|
|
115
|
+
label: t("tags.form.name") + " (en)",
|
|
116
|
+
type: "text",
|
|
117
|
+
validators: ["required"],
|
|
118
|
+
},
|
|
119
|
+
color:{
|
|
120
|
+
label: t("tags.form.color"),
|
|
121
|
+
type: "color",
|
|
122
|
+
validators: ["required"],
|
|
123
|
+
},
|
|
124
|
+
description: {
|
|
125
|
+
label: t("tags.form.description"),
|
|
126
|
+
type:"textarea"
|
|
127
|
+
},
|
|
128
|
+
topic: {
|
|
129
|
+
label: t("tags.form.topic"),
|
|
130
|
+
type:"textarea"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fetchTags = async () => {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(`/api/tags?page=${page.value}&limit=${limit.value}`);
|
|
137
|
+
const data = await response.json();
|
|
138
|
+
tags.value = data;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const handleDeleteTag = async (tag) => {
|
|
145
|
+
try {
|
|
146
|
+
await fetch(`/api/tags/${tag.tag_id}`, { method: 'DELETE' })
|
|
147
|
+
fetchTags();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const closeForm = () => {
|
|
153
|
+
isOpen.value = false;
|
|
154
|
+
isEdit.value = false;
|
|
155
|
+
formValue.value = {};
|
|
156
|
+
}
|
|
157
|
+
const handleEditTag = async (tag) => {
|
|
158
|
+
isEdit.value = true;
|
|
159
|
+
isOpen.value = true;
|
|
160
|
+
formValue.value = tag;
|
|
161
|
+
formValue.value['locale'] = { "en": tag.value_en, "uk": tag.value };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const handleCreateTag = async () => {
|
|
165
|
+
try {
|
|
166
|
+
let result = await form.value.validate();
|
|
167
|
+
if (result) {
|
|
168
|
+
notify({
|
|
169
|
+
type: "warning",
|
|
170
|
+
title: t("common.actions.warning"),
|
|
171
|
+
message: t("tags.createTagFailed"),
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
formValue.value['locale'] = { "en": formValue.value['value:en'], "uk": formValue.value['value'] };
|
|
176
|
+
|
|
177
|
+
if (isEdit.value) {
|
|
178
|
+
await fetch(`/api/tags/${formValue.value.tag_id}`, {
|
|
179
|
+
method: 'PUT',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify(formValue.value)
|
|
182
|
+
})
|
|
183
|
+
} else {
|
|
184
|
+
await fetch('/api/tags', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify(formValue.value)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fetchTags();
|
|
192
|
+
isOpen.value = false;
|
|
193
|
+
isEdit.value = false;
|
|
194
|
+
formValue.value = {};
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
watch(page, () => {
|
|
201
|
+
fetchTags();
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
onMounted(() => {
|
|
205
|
+
fetchTags();
|
|
206
|
+
})
|
|
207
|
+
</script>
|