@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.
Files changed (183) hide show
  1. package/package.json +2 -2
  2. package/src/App.css +52 -0
  3. package/src/App.vue +62 -0
  4. package/src/assets/image.png +0 -0
  5. package/src/assets/main.css +3 -0
  6. package/src/assets/tailwind-3.4.17.js +113 -0
  7. package/src/components/LanguageSwitcher.vue +73 -0
  8. package/src/components/SettingsCard.vue +40 -0
  9. package/src/components/builder/CreateForm.vue +128 -0
  10. package/src/components/builder/formTypeSchema.js +145 -0
  11. package/src/components/builder/tabs/index.ts +9 -0
  12. package/src/components/builder/tabs/vs-builder-edit.vue +133 -0
  13. package/src/components/builder/tabs/vs-builder-monaco.vue +29 -0
  14. package/src/components/builder/tabs/vs-builder-preview.vue +39 -0
  15. package/src/components/builder/vs-builder-datatable-controls.vue +138 -0
  16. package/src/components/builder/vs-builder-datatable-form.vue +80 -0
  17. package/src/components/builder/vs-builder-datatable.vue +191 -0
  18. package/src/components/builder/vs-builder-list-item.vue +110 -0
  19. package/src/components/collections/CollectionsBreadcrumb.vue +52 -0
  20. package/src/components/collections/CollectionsGrid.vue +176 -0
  21. package/src/components/collections/ContentBlock.vue +75 -0
  22. package/src/components/collections/formWrapper.vue +156 -0
  23. package/src/components/dashboard/ContentItem.vue +82 -0
  24. package/src/components/dashboard/DashboardHeader.vue +33 -0
  25. package/src/components/dashboard/QuickActions.vue +84 -0
  26. package/src/components/dashboard/RecentContent.vue +54 -0
  27. package/src/components/dashboard/StatCard.vue +63 -0
  28. package/src/components/dashboard/StatsGrid.vue +28 -0
  29. package/src/components/form-components/MonacoEditor.vue +104 -0
  30. package/src/components/form-components/VsFormMeta.vue +40 -0
  31. package/src/components/form-components/VsFormTags.vue +150 -0
  32. package/src/components/form-components/custom-datatable/vs-form-custom-datatable-add.vue +84 -0
  33. package/src/components/form-components/custom-datatable/vs-form-custom-datatable-controls.vue +106 -0
  34. package/src/components/form-components/index.js +23 -0
  35. package/src/components/form-components/reference/vs-form-reference-add.vue +92 -0
  36. package/src/components/form-components/reference/vs-form-reference-controls.vue +101 -0
  37. package/src/components/form-components/reference-list/referenceOptionList.js +78 -0
  38. package/src/components/form-components/reference-list/vs-form-reference-add.vue +145 -0
  39. package/src/components/form-components/reference-list/vs-form-reference-choce.vue +39 -0
  40. package/src/components/form-components/reference-list/vs-form-reference-controls.vue +110 -0
  41. package/src/components/form-components/reference-skeleton/about-skeleton.vue +37 -0
  42. package/src/components/form-components/reference-skeleton/banner-skeleton.vue +29 -0
  43. package/src/components/form-components/reference-skeleton/body-skeleton.vue +56 -0
  44. package/src/components/form-components/reference-skeleton/cards-skeleton.vue +47 -0
  45. package/src/components/form-components/reference-skeleton/documents-skeleton.vue +64 -0
  46. package/src/components/form-components/reference-skeleton/faq-skeleton.vue +64 -0
  47. package/src/components/form-components/reference-skeleton/form-skeleton.vue +41 -0
  48. package/src/components/form-components/reference-skeleton/index.js +36 -0
  49. package/src/components/form-components/reference-skeleton/infoLine-skeleton.vue +37 -0
  50. package/src/components/form-components/reference-skeleton/news-skeleton.vue +54 -0
  51. package/src/components/form-components/reference-skeleton/slider-skeleton.vue +41 -0
  52. package/src/components/form-components/reference-skeleton/tabs-skeleton.vue +40 -0
  53. package/src/components/form-components/reference-skeleton/team-skeleton.vue +103 -0
  54. package/src/components/form-components/reference-skeleton/usefulLinks-skeleton.vue +52 -0
  55. package/src/components/form-components/reference-skeleton/video-skeleton.vue +36 -0
  56. package/src/components/form-components/testReferenceTypes.js +773 -0
  57. package/src/components/form-components/vs-form-color-picker.vue +29 -0
  58. package/src/components/form-components/vs-form-custom-datatable.vue +214 -0
  59. package/src/components/form-components/vs-form-integer.vue +86 -0
  60. package/src/components/form-components/vs-form-key-value.vue +201 -0
  61. package/src/components/form-components/vs-form-marcdown-md.vue +3 -0
  62. package/src/components/form-components/vs-form-media-select.vue +780 -0
  63. package/src/components/form-components/vs-form-reference-list.vue +97 -0
  64. package/src/components/form-components/vs-form-reference.vue +59 -0
  65. package/src/components/form-components/vs-form-relation.vue +30 -0
  66. package/src/components/form-components/vs-form-reletion-link.vue +34 -0
  67. package/src/components/form-components/vs-form-select-collection.vue +0 -0
  68. package/src/components/form-components/vs-form-slug.vue +72 -0
  69. package/src/components/form-components/vs-form-tiptap.vue +7 -0
  70. package/src/components/form-components/vs-richtext-md.vue +3 -0
  71. package/src/components/icons/BellIcon.vue +17 -0
  72. package/src/components/icons/GlobeIcon.vue +18 -0
  73. package/src/components/icons/KeyIcon.vue +20 -0
  74. package/src/components/icons/PaletteIcon.vue +22 -0
  75. package/src/components/icons/SettingsIcon.vue +19 -0
  76. package/src/components/icons/ShieldIcon.vue +18 -0
  77. package/src/components/icons/UsersIcon.vue +19 -0
  78. package/src/components/icons/icon-chevron-right.vue +16 -0
  79. package/src/components/icons/icon-drag.vue +20 -0
  80. package/src/components/icons/icon-file-text.vue +21 -0
  81. package/src/components/icons/icon-folder.vue +18 -0
  82. package/src/components/icons/icon-grid.vue +17 -0
  83. package/src/components/icons/icon-group.vue +19 -0
  84. package/src/components/icons/icon-home.vue +16 -0
  85. package/src/components/icons/icon-image.vue +18 -0
  86. package/src/components/icons/icon-list.vue +20 -0
  87. package/src/components/icons/icon-more.vue +17 -0
  88. package/src/components/icons/icon-plus.vue +17 -0
  89. package/src/components/icons-types/icon-array.vue +22 -0
  90. package/src/components/icons-types/icon-boolean.vue +18 -0
  91. package/src/components/icons-types/icon-datalist.vue +22 -0
  92. package/src/components/icons-types/icon-date.vue +20 -0
  93. package/src/components/icons-types/icon-datetime.vue +20 -0
  94. package/src/components/icons-types/icon-file.vue +21 -0
  95. package/src/components/icons-types/icon-gallery.vue +18 -0
  96. package/src/components/icons-types/icon-image.vue +19 -0
  97. package/src/components/icons-types/icon-integer.vue +20 -0
  98. package/src/components/icons-types/icon-merkdown.vue +18 -0
  99. package/src/components/icons-types/icon-multiselect.vue +22 -0
  100. package/src/components/icons-types/icon-number.vue +20 -0
  101. package/src/components/icons-types/icon-radio.vue +22 -0
  102. package/src/components/icons-types/icon-reference-list.vue +22 -0
  103. package/src/components/icons-types/icon-reference.vue +20 -0
  104. package/src/components/icons-types/icon-relation.vue +22 -0
  105. package/src/components/icons-types/icon-richtext.vue +18 -0
  106. package/src/components/icons-types/icon-select.vue +22 -0
  107. package/src/components/icons-types/icon-slug.vue +19 -0
  108. package/src/components/icons-types/icon-text.vue +19 -0
  109. package/src/components/icons-types/index.js +43 -0
  110. package/src/components/layout/Layout.vue +67 -0
  111. package/src/components/layout/Sidebar.vue +128 -0
  112. package/src/components/media/FileUploadProgress.vue +29 -0
  113. package/src/components/media/MediaBreadcrumb.vue +42 -0
  114. package/src/components/media/MediaCreateFolder.vue +59 -0
  115. package/src/components/media/MediaFileInfo.vue +148 -0
  116. package/src/components/media/MediaGrid.vue +148 -0
  117. package/src/components/media/MediaList.vue +148 -0
  118. package/src/components/media/MediaViewControls.vue +38 -0
  119. package/src/components/media/TypeTag.vue +23 -0
  120. package/src/components/menu/AddNewItemInTree.vue +75 -0
  121. package/src/components/menu/MenuBody.vue +149 -0
  122. package/src/components/menu/MenuItem.vue +73 -0
  123. package/src/components/menu/MenuList.vue +101 -0
  124. package/src/components/referencec/index.ts +7 -0
  125. package/src/components/referencec/vs-reference-faq.vue +61 -0
  126. package/src/components/referencec/vs-reference-user-card.vue +40 -0
  127. package/src/components/settings/NotificationSettings.vue +32 -0
  128. package/src/components/settings/SettingsTable.vue +50 -0
  129. package/src/components/settings/SettingsTitle.vue +33 -0
  130. package/src/components/settings/SettingsToggleItem.vue +25 -0
  131. package/src/components/sidebar/DropdownMenu.vue +34 -0
  132. package/src/components/sidebar/SettingsSidebar.vue +121 -0
  133. package/src/components/sidebar/SidebarFooter.vue +52 -0
  134. package/src/components/sidebar/SidebarHeader.vue +57 -0
  135. package/src/components/sidebar/SidebarMenu.vue +78 -0
  136. package/src/components/ui/EmptyData.vue +76 -0
  137. package/src/components/ui/UniversalTable.vue +310 -0
  138. package/src/components/ui/UniversalTableFilters.vue +0 -0
  139. package/src/components/ui/UniversalTablePagination.vue +118 -0
  140. package/src/components/ui/VsPreview.vue +75 -0
  141. package/src/composables/useCollectionView.ts +21 -0
  142. package/src/composables/useDebounce.ts +26 -0
  143. package/src/composables/useMonaco.ts +28 -0
  144. package/src/composables/useTheme.ts +40 -0
  145. package/src/content/test-slug/metadata.json +1 -0
  146. package/src/i18n.ts +75 -0
  147. package/src/index.css +3 -0
  148. package/src/locales/en.json +778 -0
  149. package/src/locales/uk.json +797 -0
  150. package/src/main.ts +41 -0
  151. package/src/pages/Dashboard.vue +168 -0
  152. package/src/pages/EmailPage.vue +183 -0
  153. package/src/pages/FeedbackPage.vue +232 -0
  154. package/src/pages/MediaPage.vue +372 -0
  155. package/src/pages/TagsPage.vue +207 -0
  156. package/src/pages/builder/BuilderPage.vue +195 -0
  157. package/src/pages/builder/EditCollectionPage.vue +163 -0
  158. package/src/pages/collections/ArticlesPage.vue +385 -0
  159. package/src/pages/collections/CollectionsPage.vue +146 -0
  160. package/src/pages/collections/SingletonsPage.vue +119 -0
  161. package/src/pages/collections/contentForm.vue +484 -0
  162. package/src/pages/collections/schema/seo.ts +27 -0
  163. package/src/pages/menu/MenuAddPage.vue +123 -0
  164. package/src/pages/menu/MenuItemPage.vue +183 -0
  165. package/src/pages/menu/MenuPage.vue +133 -0
  166. package/src/pages/settings/ApiKeys.vue +75 -0
  167. package/src/pages/settings/Appearance.vue +80 -0
  168. package/src/pages/settings/Logs.vue +260 -0
  169. package/src/pages/settings/PermissionsPage.vue +237 -0
  170. package/src/pages/settings/Settings.vue +186 -0
  171. package/src/pages/settings/Users.vue +109 -0
  172. package/src/pages/settings/general.vue +154 -0
  173. package/src/pages/settings/generalScheme.js +132 -0
  174. package/src/pages/users/AddUser.vue +106 -0
  175. package/src/pages/users/UsersPage.vue +98 -0
  176. package/src/props/builder.ts +67 -0
  177. package/src/props/content.ts +56 -0
  178. package/src/props/media.ts +63 -0
  179. package/src/router/index.ts +181 -0
  180. package/src/types/fastify-auth.d.ts +4 -0
  181. package/src/utils/getField.js +270 -0
  182. package/src/utils/translit.js +19 -0
  183. 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>