@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,780 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-bind="$attrs"
|
|
4
|
+
class="flex gap-4 flex-wrap"
|
|
5
|
+
>
|
|
6
|
+
<!-- Множинний режим: показуємо всі вибрані файли -->
|
|
7
|
+
<template v-if="isMultiple">
|
|
8
|
+
<div
|
|
9
|
+
v-for="(file, index) in currentValueArray"
|
|
10
|
+
:key="index"
|
|
11
|
+
class="relative group"
|
|
12
|
+
>
|
|
13
|
+
<img :src="getFilePreview(file)" alt="Selected File" class="w-32 h-32 object-cover rounded-lg">
|
|
14
|
+
<div
|
|
15
|
+
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-lg gap-2"
|
|
16
|
+
>
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
@click="fileDelete(file, index)"
|
|
20
|
+
class="p-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
|
21
|
+
>
|
|
22
|
+
<Trash2 class="w-4 h-4" />
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
@click="fileDownload(getFilePath(file))"
|
|
27
|
+
class="p-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
|
28
|
+
>
|
|
29
|
+
<Download class="w-4 h-4" />
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
<div
|
|
33
|
+
class="absolute bottom-full left-0 mb-2 px-3 py-1.5 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10 max-w-[200px] truncate"
|
|
34
|
+
:title="getFileName(file)"
|
|
35
|
+
>
|
|
36
|
+
{{ getFileName(file) }}
|
|
37
|
+
<div class="absolute top-full left-4 border-4 border-transparent border-t-gray-900"></div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
<!-- Одиночний режим: показуємо один файл -->
|
|
42
|
+
<div class="relative group" v-else-if="currentValue">
|
|
43
|
+
<img :src="currentValue.preview || currentValue" alt="Selected File" class="w-32 h-32 object-cover rounded-lg">
|
|
44
|
+
<div
|
|
45
|
+
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-lg gap-2"
|
|
46
|
+
>
|
|
47
|
+
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
@click="fileDelete(currentValue?.filepath || currentValue?.file_path || currentValue)"
|
|
51
|
+
class="p-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
|
52
|
+
>
|
|
53
|
+
<Trash2 class="w-4 h-4" />
|
|
54
|
+
</button>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
@click="fileDownload(currentValue?.filepath || currentValue?.file_path || currentValue)"
|
|
58
|
+
class="p-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
|
59
|
+
>
|
|
60
|
+
<Download class="w-4 h-4" />
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
<div
|
|
64
|
+
class="absolute bottom-full left-0 mb-2 px-3 py-1.5 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10 max-w-[200px] truncate"
|
|
65
|
+
:title="fileName"
|
|
66
|
+
>
|
|
67
|
+
{{ fileName }}
|
|
68
|
+
<div class="absolute top-full left-4 border-4 border-transparent border-t-gray-900"></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div
|
|
72
|
+
ref="triggerButton"
|
|
73
|
+
class="relative w-32 h-32 flex items-center justify-center border border-dotted border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-500/10 hover:cursor-pointer transition-all"
|
|
74
|
+
:class="{ 'border-blue-500 bg-blue-500/10': isOpen, 'opacity-50 cursor-not-allowed': isUploading }"
|
|
75
|
+
@click="toggleDropdown"
|
|
76
|
+
>
|
|
77
|
+
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin text-blue-500" />
|
|
78
|
+
<Plus v-else class="w-4 h-4" />
|
|
79
|
+
|
|
80
|
+
<!-- Overlay лоадер -->
|
|
81
|
+
<div
|
|
82
|
+
v-if="isUploading"
|
|
83
|
+
class="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded-lg z-10"
|
|
84
|
+
>
|
|
85
|
+
<div class="flex flex-col items-center gap-2">
|
|
86
|
+
<Loader2 class="w-6 h-6 animate-spin text-blue-500" />
|
|
87
|
+
<span class="text-xs text-gray-600">{{ $t("media.uploading") }}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<Teleport to="body">
|
|
92
|
+
<div v-if="isOpen" class="fixed bg-white z-50 border border-gray-200 rounded-lg shadow-lg flex flex-col items-center justify-center" :style="dropdownStyle">
|
|
93
|
+
<div
|
|
94
|
+
class="relative overflow-hidden flex justify-start items-center w-full gap-2 p-4 hover:bg-blue-50 text-blue-700 border hover:border-blue-200 dark:bg-slate-700 dark:text-slate-100 hover:cursor-pointer"
|
|
95
|
+
:class="{ 'opacity-50 cursor-not-allowed': isUploading }"
|
|
96
|
+
@click="!isUploading && triggerFileInput()"
|
|
97
|
+
>
|
|
98
|
+
<input
|
|
99
|
+
type="file"
|
|
100
|
+
ref="fileInput"
|
|
101
|
+
class="absolute justify-center bottom-0 left-0 w-full opacity-0"
|
|
102
|
+
@change="handleFileUpload"
|
|
103
|
+
@click.stop
|
|
104
|
+
:multiple="isMultiple"
|
|
105
|
+
:disabled="isUploading"
|
|
106
|
+
/>
|
|
107
|
+
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
|
108
|
+
<Download v-else />
|
|
109
|
+
<span class="whitespace-nowrap text-sm">{{ isUploading ? $t("media.uploading") : $t("media.upload") }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div
|
|
112
|
+
@click="isMediaSelectOpen = true"
|
|
113
|
+
class="flex justify-start items-center w-full gap-2 p-4 hover:bg-blue-50 text-blue-700 border hover:border-blue-200 dark:bg-slate-700 dark:text-slate-100 hover:cursor-pointer"
|
|
114
|
+
>
|
|
115
|
+
<Image />
|
|
116
|
+
<span class="whitespace-nowrap text-sm">{{ $t("media.selectFromMedia") }}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</Teleport>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
</div>
|
|
123
|
+
<Teleport to="body">
|
|
124
|
+
<div
|
|
125
|
+
v-if="isMediaSelectOpen"
|
|
126
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
|
127
|
+
@click="closeMediaSelect"
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col"
|
|
131
|
+
@click.stop
|
|
132
|
+
>
|
|
133
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
134
|
+
<h3 class="text-lg font-semibold">{{ $t('media.selectFiles') }}</h3>
|
|
135
|
+
<button
|
|
136
|
+
@click="closeMediaSelect"
|
|
137
|
+
class="text-gray-400 hover:text-gray-600"
|
|
138
|
+
>
|
|
139
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
140
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
141
|
+
</svg>
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="flex items-center justify-between gap-4 p-4 border-b">
|
|
145
|
+
<MediaBreadcrumb
|
|
146
|
+
:current-path="currentPath"
|
|
147
|
+
@navigate="navigateToFolder"
|
|
148
|
+
@navigate-back="navigateBack"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
<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 mx-4">
|
|
152
|
+
<div class="flex items-center justify-between gap-4">
|
|
153
|
+
<div class="flex items-center gap-4 flex-1">
|
|
154
|
+
<div class="relative flex-1 max-w-md">
|
|
155
|
+
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500" />
|
|
156
|
+
<input
|
|
157
|
+
type="text"
|
|
158
|
+
:placeholder="$t('media.search')"
|
|
159
|
+
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"
|
|
160
|
+
v-model="searchQuery"
|
|
161
|
+
>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="flex items-center gap-2 relative">
|
|
164
|
+
<span class="text-xs font-bold">{{ $t("media.sortBy.title") + ': ' }}</span>
|
|
165
|
+
<button
|
|
166
|
+
ref="sortByButton"
|
|
167
|
+
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
|
|
168
|
+
disabled:pointer-events-none disabled:opacity-50
|
|
169
|
+
hover:text-accent-foreground hover:bg-slate-50
|
|
170
|
+
dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-600"
|
|
171
|
+
@click="isSortByOpen = !isSortByOpen"
|
|
172
|
+
>
|
|
173
|
+
<ChevronDown class="w-4 h-4" />
|
|
174
|
+
<span class="text-xs font-bold">{{ sortByOptions.get(sortBy) }}</span>
|
|
175
|
+
<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 rounded-md shadow-lg">
|
|
176
|
+
<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 cursor-pointer" @click.stop="sorting('name')">{{ $t("media.sortBy.name") }}</span>
|
|
177
|
+
<span class="text-left text-xs font-bold w-full p-2 hover:bg-slate-100 dark:hover:bg-slate-600 cursor-pointer" @click.stop="sorting('filesize')">{{ $t("media.sortBy.filesize") }}</span>
|
|
178
|
+
</div>
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="flex items-center gap-2">
|
|
183
|
+
<button
|
|
184
|
+
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"
|
|
185
|
+
:class="{
|
|
186
|
+
'shadow bg-blue-600 hover:bg-blue-700 text-white': viewMode === 'grid',
|
|
187
|
+
'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',
|
|
188
|
+
}"
|
|
189
|
+
@click="viewMode = 'grid'"
|
|
190
|
+
>
|
|
191
|
+
<LayoutGrid class="w-4 h-4" />
|
|
192
|
+
</button>
|
|
193
|
+
<button
|
|
194
|
+
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"
|
|
195
|
+
:class="{
|
|
196
|
+
'shadow bg-blue-600 hover:bg-blue-700 text-white': viewMode === 'list',
|
|
197
|
+
'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',
|
|
198
|
+
}"
|
|
199
|
+
@click="viewMode = 'list'"
|
|
200
|
+
>
|
|
201
|
+
<List class="w-4 h-4" />
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="flex flex-col gap-4 overflow-y-auto p-4 flex-1">
|
|
208
|
+
<MediaGrid
|
|
209
|
+
v-if="viewMode === 'grid'"
|
|
210
|
+
:files="files"
|
|
211
|
+
:selected-file="isMultiple ? null : selectedFile"
|
|
212
|
+
:selected-files="isMultiple ? selectedFiles : []"
|
|
213
|
+
@file-click="handleFileClick"
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
<MediaList
|
|
217
|
+
v-else
|
|
218
|
+
:files="files"
|
|
219
|
+
:selected-file="isMultiple ? null : selectedFile"
|
|
220
|
+
:selected-files="isMultiple ? selectedFiles : []"
|
|
221
|
+
@file-click="handleFileClick"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div class="p-4 border-t flex items-center justify-between">
|
|
226
|
+
<div v-if="isMultiple && selectedFiles.length > 0" class="text-sm text-gray-600 dark:text-gray-400">
|
|
227
|
+
{{ $t("media.selectedFiles", { count: selectedFiles.length }) }}
|
|
228
|
+
</div>
|
|
229
|
+
<div v-else></div>
|
|
230
|
+
<button
|
|
231
|
+
@click="addFile"
|
|
232
|
+
:disabled="isMultiple ? selectedFiles.length === 0 : !selectedFile"
|
|
233
|
+
class="flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 ml-auto bg-blue-600 hover:bg-blue-700 text-white shadow-md hover:shadow-lg transition-all duration-200 transform hover:scale-105"
|
|
234
|
+
>
|
|
235
|
+
<Plus class="w-4 h-4 mr-2" />
|
|
236
|
+
{{ $t("common.actions.add") }}
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</Teleport>
|
|
242
|
+
</template>
|
|
243
|
+
|
|
244
|
+
<script setup>
|
|
245
|
+
import { onMounted, watch, computed } from "vue";
|
|
246
|
+
import { Plus, Download, Image, LayoutGrid, List, Trash2, Search, ChevronDown, Loader2} from "lucide-vue-next";
|
|
247
|
+
import { ref } from "vue";
|
|
248
|
+
import MediaGrid from "../media/MediaGrid.vue";
|
|
249
|
+
import MediaList from "../media/MediaList.vue";
|
|
250
|
+
import MediaBreadcrumb from "../media/MediaBreadcrumb.vue";
|
|
251
|
+
import { useRoute, useRouter } from "vue-router";
|
|
252
|
+
import { useI18n } from "vue-i18n";
|
|
253
|
+
|
|
254
|
+
const { t } = useI18n();
|
|
255
|
+
|
|
256
|
+
const route = useRoute();
|
|
257
|
+
const router = useRouter();
|
|
258
|
+
|
|
259
|
+
const modelValue = defineModel();
|
|
260
|
+
const emit = defineEmits(['change']);
|
|
261
|
+
|
|
262
|
+
// Пропси
|
|
263
|
+
const props = defineProps({
|
|
264
|
+
multiple: {
|
|
265
|
+
type: Boolean,
|
|
266
|
+
default: false
|
|
267
|
+
},
|
|
268
|
+
multi: {
|
|
269
|
+
type: Boolean,
|
|
270
|
+
default: false
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Computed для визначення чи multiple режим
|
|
275
|
+
const isMultiple = computed(() => props.multiple || props.multi);
|
|
276
|
+
|
|
277
|
+
// Локальний стан для відображення, якщо modelValue не існує
|
|
278
|
+
const localValue = ref(null);
|
|
279
|
+
|
|
280
|
+
// Computed для поточного значення
|
|
281
|
+
const currentValue = computed(() => {
|
|
282
|
+
return modelValue.value || localValue.value;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Computed для масиву значень (для multiple режиму)
|
|
286
|
+
const currentValueArray = computed(() => {
|
|
287
|
+
if (!isMultiple.value) return [];
|
|
288
|
+
const value = currentValue.value;
|
|
289
|
+
if (!value) return [];
|
|
290
|
+
if (Array.isArray(value)) return value;
|
|
291
|
+
// Якщо значення не масив, конвертуємо в масив
|
|
292
|
+
return [value];
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Computed для назви файлу
|
|
296
|
+
const fileName = computed(() => {
|
|
297
|
+
if (!currentValue.value) return '';
|
|
298
|
+
|
|
299
|
+
// Якщо це об'єкт з властивостями
|
|
300
|
+
if (typeof currentValue.value === 'object') {
|
|
301
|
+
// Спочатку шукаємо саме назву файлу, а не шлях
|
|
302
|
+
return currentValue.value.filename ||
|
|
303
|
+
currentValue.value.file_name ||
|
|
304
|
+
currentValue.value.native_file_name ||
|
|
305
|
+
'File';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Якщо це рядок (шлях до файлу), витягуємо тільки назву
|
|
309
|
+
if (typeof currentValue.value === 'string') {
|
|
310
|
+
return currentValue.value.split('/').pop() || currentValue.value;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return 'File';
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const isOpen = ref(false);
|
|
317
|
+
const isMediaSelectOpen = ref(false);
|
|
318
|
+
const viewMode = ref("grid");
|
|
319
|
+
const currentPath = ref([]);
|
|
320
|
+
const files = ref([]);
|
|
321
|
+
const pathHistory = ref([]);
|
|
322
|
+
const selectedFile = ref();
|
|
323
|
+
const selectedFiles = ref([]); // Масив вибраних файлів для multiple режиму
|
|
324
|
+
const fileInput = ref(null);
|
|
325
|
+
const isUploading = ref(false); // Стан завантаження файлів
|
|
326
|
+
const triggerButton = ref(null);
|
|
327
|
+
const sortByButton = ref(null);
|
|
328
|
+
const dropdownStyle = ref({});
|
|
329
|
+
const searchQuery = ref("");
|
|
330
|
+
const isSortByOpen = ref(false);
|
|
331
|
+
const sortBy = ref("name");
|
|
332
|
+
|
|
333
|
+
const sortByOptions = new Map([
|
|
334
|
+
["name", t("media.sortBy.name")],
|
|
335
|
+
["filesize", t("media.sortBy.filesize")],
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
// Допоміжні функції для роботи з файлами
|
|
339
|
+
const getFilePath = (file) => {
|
|
340
|
+
if (typeof file === 'string') return file;
|
|
341
|
+
return file?.filepath || file?.file_path || file;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const getFileName = (file) => {
|
|
345
|
+
if (!file) return '';
|
|
346
|
+
if (typeof file === 'string') {
|
|
347
|
+
return file.split('/').pop() || file;
|
|
348
|
+
}
|
|
349
|
+
return file.filename || file.file_name || file.native_file_name || 'File';
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const getFilePreview = (file) => {
|
|
353
|
+
if (typeof file === 'string') return file;
|
|
354
|
+
return file?.preview || `/file/resize?filepath=${getFilePath(file)}&h=137&quality=75`;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const triggerFileInput = () => {
|
|
358
|
+
if (fileInput.value) {
|
|
359
|
+
fileInput.value.click();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const toggleDropdown = () => {
|
|
364
|
+
isOpen.value = !isOpen.value;
|
|
365
|
+
if (isOpen.value) {
|
|
366
|
+
updateDropdownPosition();
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const updateDropdownPosition = () => {
|
|
371
|
+
if (triggerButton.value) {
|
|
372
|
+
const rect = triggerButton.value.getBoundingClientRect();
|
|
373
|
+
dropdownStyle.value = {
|
|
374
|
+
top: `${rect.bottom + 10}px`,
|
|
375
|
+
left: `${rect.left + rect.width / 2}px`,
|
|
376
|
+
transform: 'translateX(-50%)',
|
|
377
|
+
width: '200px'
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const handleFileClick = (file) => {
|
|
383
|
+
if (file.type === "dir") {
|
|
384
|
+
navigateToFolder([...currentPath.value, file.name]);
|
|
385
|
+
} else {
|
|
386
|
+
if (isMultiple.value) {
|
|
387
|
+
// Множинний вибір: додаємо/видаляємо файл з масиву
|
|
388
|
+
const index = selectedFiles.value.findIndex(f => f.id === file.id);
|
|
389
|
+
if (index >= 0) {
|
|
390
|
+
selectedFiles.value.splice(index, 1);
|
|
391
|
+
} else {
|
|
392
|
+
selectedFiles.value.push(file);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
// Одиночний вибір
|
|
396
|
+
selectedFile.value = file.id === selectedFile.value?.id ? null : file;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const sorting = (sort) => {
|
|
402
|
+
sortBy.value = sort;
|
|
403
|
+
isSortByOpen.value = false;
|
|
404
|
+
|
|
405
|
+
files.value = files.value.sort((a, b) => {
|
|
406
|
+
if(a?.type === "dir") return -1;
|
|
407
|
+
|
|
408
|
+
if (sort === "name") {
|
|
409
|
+
return a?.name?.localeCompare(b?.name);
|
|
410
|
+
} else if (sort === "filesize") {
|
|
411
|
+
return b?.filesize - a?.filesize;
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const searchFiles = async () => {
|
|
417
|
+
try {
|
|
418
|
+
const path = currentPath.value.join("/");
|
|
419
|
+
const response = await fetch(`/api/cms-media?subdir=${path}${searchQuery.value ? '&search=' + searchQuery.value : ''}`, {
|
|
420
|
+
method: "GET",
|
|
421
|
+
});
|
|
422
|
+
const data = await response.json();
|
|
423
|
+
if (data.data) {
|
|
424
|
+
files.value = data.data.map((file) => ({
|
|
425
|
+
...file,
|
|
426
|
+
id: file.type === "dir" ? `dir-${file.name}` : file.id,
|
|
427
|
+
preview: `/file/resize?filepath=${file.filepath}&h=137&quality=75`,
|
|
428
|
+
}));
|
|
429
|
+
sorting(sortBy.value);
|
|
430
|
+
} else {
|
|
431
|
+
files.value = [];
|
|
432
|
+
}
|
|
433
|
+
} catch (err) {
|
|
434
|
+
console.error("Search error:", err);
|
|
435
|
+
files.value = [];
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const addFile = () => {
|
|
440
|
+
if (isMultiple.value) {
|
|
441
|
+
// Множинний режим: додаємо всі вибрані файли
|
|
442
|
+
if (selectedFiles.value.length > 0) {
|
|
443
|
+
const currentArray = Array.isArray(modelValue.value) ? [...modelValue.value] :
|
|
444
|
+
(modelValue.value ? [modelValue.value] : []);
|
|
445
|
+
const currentLocalArray = Array.isArray(localValue.value) ? [...localValue.value] :
|
|
446
|
+
(localValue.value ? [localValue.value] : []);
|
|
447
|
+
|
|
448
|
+
// Додаємо нові файли
|
|
449
|
+
selectedFiles.value.forEach(file => {
|
|
450
|
+
const filePath = file.filepath || file.file_path;
|
|
451
|
+
// Перевіряємо чи файл вже не додано
|
|
452
|
+
if (!currentArray.some(f => {
|
|
453
|
+
const existingPath = typeof f === 'string' ? f : (f?.filepath || f?.file_path);
|
|
454
|
+
return existingPath === filePath;
|
|
455
|
+
})) {
|
|
456
|
+
currentArray.push(filePath);
|
|
457
|
+
currentLocalArray.push(file);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Оновлюємо значення
|
|
462
|
+
if (modelValue) {
|
|
463
|
+
modelValue.value = currentArray;
|
|
464
|
+
}
|
|
465
|
+
localValue.value = currentLocalArray;
|
|
466
|
+
|
|
467
|
+
// Емітимо для додаткових обробників
|
|
468
|
+
emit('change', currentArray);
|
|
469
|
+
|
|
470
|
+
// Скидаємо вибрані файли
|
|
471
|
+
selectedFiles.value = [];
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
// Одиночний режим
|
|
475
|
+
if (selectedFile.value) {
|
|
476
|
+
const filePath = selectedFile.value.filepath || selectedFile.value.file_path;
|
|
477
|
+
|
|
478
|
+
// Завжди встановлюємо в modelValue, навіть якщо він null
|
|
479
|
+
if (modelValue) {
|
|
480
|
+
modelValue.value = filePath; // Встановлюємо file_path для v-model
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Також зберігаємо повний об'єкт для відображення
|
|
484
|
+
localValue.value = selectedFile.value;
|
|
485
|
+
|
|
486
|
+
// Емітимо для додаткових обробників
|
|
487
|
+
emit('change', filePath);
|
|
488
|
+
|
|
489
|
+
// Скидаємо вибраний файл
|
|
490
|
+
selectedFile.value = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
closeMediaSelect();
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const closeMediaSelect = () => {
|
|
497
|
+
isMediaSelectOpen.value = false;
|
|
498
|
+
// Не скидаємо selectedFile/selectedFiles при закритті, щоб зберегти вибір
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const navigateToFolder = (path) => {
|
|
502
|
+
currentPath.value = path;
|
|
503
|
+
pathHistory.value = [...pathHistory.value, path];
|
|
504
|
+
selectedFile.value = null; // Скидаємо вибраний файл при навігації
|
|
505
|
+
if (isMultiple.value) {
|
|
506
|
+
selectedFiles.value = []; // Скидаємо вибрані файли при навігації
|
|
507
|
+
}
|
|
508
|
+
fetchFiles();
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const navigateBack = () => {
|
|
512
|
+
if (pathHistory.value.length > 1) {
|
|
513
|
+
pathHistory.value.pop(); // Remove current path
|
|
514
|
+
currentPath.value = pathHistory.value[pathHistory.value.length - 1];
|
|
515
|
+
selectedFile.value = null; // Скидаємо вибраний файл при поверненні
|
|
516
|
+
if (isMultiple.value) {
|
|
517
|
+
selectedFiles.value = []; // Скидаємо вибрані файли при поверненні
|
|
518
|
+
}
|
|
519
|
+
fetchFiles();
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const fetchFiles = async () => {
|
|
524
|
+
try {
|
|
525
|
+
const path = currentPath.value.join("/");
|
|
526
|
+
const response = await fetch(`/api/cms-media?subdir=${path}${searchQuery.value ? '&search=' + searchQuery.value : ''}`, {
|
|
527
|
+
method: "GET",
|
|
528
|
+
});
|
|
529
|
+
const data = await response.json();
|
|
530
|
+
if (data.data) {
|
|
531
|
+
files.value = data.data.map((file) => ({
|
|
532
|
+
...file,
|
|
533
|
+
id: file.type === "dir" ? `dir-${file.name}` : file.id,
|
|
534
|
+
preview: `/file/resize?filepath=${file.filepath}&h=137&quality=75`,
|
|
535
|
+
}));
|
|
536
|
+
sorting(sortBy.value);
|
|
537
|
+
} else {
|
|
538
|
+
files.value = [];
|
|
539
|
+
}
|
|
540
|
+
} catch (err) {
|
|
541
|
+
console.error("Fetch error:", err);
|
|
542
|
+
files.value = [];
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const fileDelete = async (file, index) => {
|
|
547
|
+
try {
|
|
548
|
+
const filepath = typeof file === 'string' ? file : getFilePath(file);
|
|
549
|
+
await fetch(`/file/delete${filepath}`);
|
|
550
|
+
|
|
551
|
+
if (isMultiple.value) {
|
|
552
|
+
// Множинний режим: видаляємо файл з масиву
|
|
553
|
+
const currentArray = Array.isArray(modelValue.value) ? [...modelValue.value] : [];
|
|
554
|
+
const currentLocalArray = Array.isArray(localValue.value) ? [...localValue.value] : [];
|
|
555
|
+
|
|
556
|
+
// Видаляємо файл за індексом
|
|
557
|
+
if (index !== undefined && index >= 0) {
|
|
558
|
+
currentArray.splice(index, 1);
|
|
559
|
+
currentLocalArray.splice(index, 1);
|
|
560
|
+
} else {
|
|
561
|
+
// Якщо індекс не передано, шукаємо файл за шляхом
|
|
562
|
+
const pathIndex = currentArray.findIndex(f => {
|
|
563
|
+
const existingPath = typeof f === 'string' ? f : (f?.filepath || f?.file_path);
|
|
564
|
+
return existingPath === filepath;
|
|
565
|
+
});
|
|
566
|
+
if (pathIndex >= 0) {
|
|
567
|
+
currentArray.splice(pathIndex, 1);
|
|
568
|
+
currentLocalArray.splice(pathIndex, 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Оновлюємо значення
|
|
573
|
+
if (modelValue) {
|
|
574
|
+
modelValue.value = currentArray.length > 0 ? currentArray : null;
|
|
575
|
+
}
|
|
576
|
+
localValue.value = currentLocalArray.length > 0 ? currentLocalArray : null;
|
|
577
|
+
|
|
578
|
+
// Емітимо подію change
|
|
579
|
+
emit('change', currentArray.length > 0 ? currentArray : null);
|
|
580
|
+
} else {
|
|
581
|
+
// Одиночний режим
|
|
582
|
+
// Очищаємо modelValue якщо він існує
|
|
583
|
+
if (modelValue) {
|
|
584
|
+
modelValue.value = null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Очищаємо localValue
|
|
588
|
+
localValue.value = null;
|
|
589
|
+
|
|
590
|
+
// Емітимо подію change з null значенням
|
|
591
|
+
emit('change', null);
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
console.error(error);
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const fileDownload = async (filepath) => {
|
|
602
|
+
try {
|
|
603
|
+
const res = await fetch(`/file/download/files/uploads${filepath}`);
|
|
604
|
+
const buffer = await res.arrayBuffer();
|
|
605
|
+
|
|
606
|
+
const type = (res.headers)['content-type'];
|
|
607
|
+
const data = new TextDecoder('utf-8').decode(buffer);
|
|
608
|
+
const blob = new Blob([data], { type });
|
|
609
|
+
const link = document.createElement('a');
|
|
610
|
+
link.setAttribute('download', filepath);
|
|
611
|
+
link.href = window.URL.createObjectURL(blob);
|
|
612
|
+
link.click();
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
console.error(error.message);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const handleFileUpload = async(event) => {
|
|
620
|
+
if (!event.target.files || event.target.files.length === 0) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const filesToUpload = Array.from(event.target.files);
|
|
625
|
+
console.log('Uploading files:', filesToUpload);
|
|
626
|
+
|
|
627
|
+
// Встановлюємо стан завантаження
|
|
628
|
+
isUploading.value = true;
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const uploadedFiles = [];
|
|
632
|
+
|
|
633
|
+
// Завантажуємо всі файли
|
|
634
|
+
for (const file of filesToUpload) {
|
|
635
|
+
// Створюємо FormData для завантаження
|
|
636
|
+
const formData = new FormData();
|
|
637
|
+
formData.append('file', file);
|
|
638
|
+
|
|
639
|
+
// Завантажуємо файл на сервер
|
|
640
|
+
const response = await fetch('/file/upload/uploads?id=1&form=form&table=table', {
|
|
641
|
+
method: 'POST',
|
|
642
|
+
body: formData
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (!response.ok) {
|
|
646
|
+
throw new Error(`Upload failed: ${response.status}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const responseData = await response.json();
|
|
650
|
+
console.log('Upload result:', responseData);
|
|
651
|
+
|
|
652
|
+
// API повертає структуру з result об'єктом
|
|
653
|
+
const result = responseData.result;
|
|
654
|
+
|
|
655
|
+
// Створюємо об'єкт з результатом завантаження
|
|
656
|
+
const newFile = {
|
|
657
|
+
file_id: result.file_id,
|
|
658
|
+
file_name: result.file_name,
|
|
659
|
+
file_path: result.file_path,
|
|
660
|
+
filepath: result.file_path, // Для сумісності
|
|
661
|
+
format: result.format,
|
|
662
|
+
size: result.size,
|
|
663
|
+
entity_id: result.entity_id,
|
|
664
|
+
dir: result.dir,
|
|
665
|
+
native_file_name: result.native_file_name,
|
|
666
|
+
preview: `/file/resize?filepath=${result.file_path}&h=137&quality=75`
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
uploadedFiles.push(newFile);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (isMultiple.value) {
|
|
673
|
+
// Множинний режим: додаємо всі завантажені файли до масиву
|
|
674
|
+
const currentArray = Array.isArray(modelValue.value) ? [...modelValue.value] :
|
|
675
|
+
(modelValue.value ? [modelValue.value] : []);
|
|
676
|
+
const currentLocalArray = Array.isArray(localValue.value) ? [...localValue.value] :
|
|
677
|
+
(localValue.value ? [localValue.value] : []);
|
|
678
|
+
|
|
679
|
+
uploadedFiles.forEach(newFile => {
|
|
680
|
+
currentArray.push(newFile.file_path);
|
|
681
|
+
currentLocalArray.push(newFile);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Оновлюємо значення
|
|
685
|
+
if (modelValue) {
|
|
686
|
+
modelValue.value = currentArray;
|
|
687
|
+
}
|
|
688
|
+
localValue.value = currentLocalArray;
|
|
689
|
+
|
|
690
|
+
// Емітимо для додаткових обробників
|
|
691
|
+
emit('change', currentArray);
|
|
692
|
+
} else {
|
|
693
|
+
// Одиночний режим: використовуємо тільки перший файл
|
|
694
|
+
const newFile = uploadedFiles[0];
|
|
695
|
+
|
|
696
|
+
// Завжди встановлюємо в modelValue, навіть якщо він null
|
|
697
|
+
if (modelValue) {
|
|
698
|
+
modelValue.value = newFile.file_path; // Встановлюємо file_path для v-model
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Також зберігаємо повний об'єкт для відображення
|
|
702
|
+
localValue.value = newFile;
|
|
703
|
+
|
|
704
|
+
// Емітимо для додаткових обробників
|
|
705
|
+
emit('change', newFile.file_path);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
isOpen.value = false; // Закриваємо меню після завантаження
|
|
709
|
+
|
|
710
|
+
} catch (error) {
|
|
711
|
+
console.error('Upload error:', error);
|
|
712
|
+
alert('Помилка завантаження файлу: ' + error.message);
|
|
713
|
+
} finally {
|
|
714
|
+
// Скидаємо стан завантаження
|
|
715
|
+
isUploading.value = false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Очищаємо input
|
|
719
|
+
event.target.value = '';
|
|
720
|
+
};
|
|
721
|
+
watch(isMediaSelectOpen, (n) => {
|
|
722
|
+
if (n) {
|
|
723
|
+
fetchFiles();
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
watch(currentPath, (n) => {
|
|
728
|
+
if (n.length > 0) {
|
|
729
|
+
router.push({ query: { path: n.join("/") } });
|
|
730
|
+
} else {
|
|
731
|
+
router.push({ query: { path: undefined } });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
let searchTimeout;
|
|
736
|
+
watch(searchQuery, (val) => {
|
|
737
|
+
clearTimeout(searchTimeout);
|
|
738
|
+
searchTimeout = setTimeout(() => {
|
|
739
|
+
searchFiles();
|
|
740
|
+
}, 500);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Скидаємо selectedFile коли modelValue змінюється ззовні
|
|
744
|
+
watch(modelValue, (newValue) => {
|
|
745
|
+
if (newValue && typeof newValue === 'object') {
|
|
746
|
+
const currentPath = selectedFile.value?.filepath || selectedFile.value?.file_path;
|
|
747
|
+
if (newValue.filepath !== currentPath && newValue.file_path !== currentPath) {
|
|
748
|
+
selectedFile.value = null;
|
|
749
|
+
}
|
|
750
|
+
} else if (newValue && typeof newValue === 'string') {
|
|
751
|
+
const currentPath = selectedFile.value?.filepath || selectedFile.value?.file_path;
|
|
752
|
+
if (newValue !== currentPath) {
|
|
753
|
+
selectedFile.value = null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
onMounted(() => {
|
|
760
|
+
currentPath.value = route.query?.path?.split("/") || [];
|
|
761
|
+
pathHistory.value = [[]]; // Initialize with root path
|
|
762
|
+
|
|
763
|
+
// Ініціалізуємо selectedFile якщо modelValue порожній
|
|
764
|
+
if (!modelValue.value) {
|
|
765
|
+
selectedFile.value = null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Додаємо обробник кліків поза dropdown
|
|
769
|
+
const handleClickOutside = (e) => {
|
|
770
|
+
if (isOpen.value && !triggerButton.value?.contains(e.target)) {
|
|
771
|
+
isOpen.value = false;
|
|
772
|
+
}
|
|
773
|
+
// Закриваємо dropdown сортування при кліку поза ним
|
|
774
|
+
if (isSortByOpen.value && sortByButton.value && !sortByButton.value.contains(e.target)) {
|
|
775
|
+
isSortByOpen.value = false;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
document.addEventListener('click', handleClickOutside);
|
|
779
|
+
});
|
|
780
|
+
</script>
|