@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,385 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, watch, inject, computed, nextTick } from "vue";
3
+ import { useRouter, useRoute } from "vue-router";
4
+ import { Plus, FileText, Calendar, Globe, User, ChevronDown, HelpCircle } from "lucide-vue-next";
5
+ import UniversalTable from "../../components/ui/UniversalTable.vue";
6
+ import UniversalTablePagination from "../../components/ui/UniversalTablePagination.vue";
7
+ import { VsInputCheckbox } from "@opengis/form";
8
+ import { useHead } from "@vueuse/head";
9
+ import VsFilter from '@opengis/filter';
10
+ import { confirm } from '@opengis/core';
11
+
12
+ import { useI18n } from "vue-i18n";
13
+ import EmptyData from "../../components/ui/EmptyData.vue";
14
+ import CollectionsBreadcrumb from "../../components/collections/CollectionsBreadcrumb.vue";
15
+ interface Article {
16
+ id: string;
17
+ title: string;
18
+ content?: string;
19
+ author: string;
20
+ category: string;
21
+ status: "published" | "draft" | "archived";
22
+ }
23
+
24
+ const route = useRoute();
25
+ const router = useRouter();
26
+
27
+ const tableData = ref<any>(null);
28
+ const searchQuery = ref("");
29
+ const { t, locale } = useI18n();
30
+ const articles = ref<any[]>([]);
31
+ const menu = inject("menu") || <any>[];
32
+ const filter = ref<string>('');
33
+ const page = ref(1);
34
+ const showColumns = ref(false);
35
+ const baseColumns = ref(["title","slug","author","publish_at","published_at","status"])
36
+ const columns = ref([]);
37
+ const options = ref<any[]>([]);
38
+ const scheme = ref([]);
39
+ const initialFilters = ref<Record<string, any>>({});
40
+ const filterKey = ref(0); // Key для примусового перерендеру VsFilter
41
+ const isLoading = ref(false); // Флаг для запобігання подвійних викликів
42
+
43
+ interface FilterData {
44
+ data: Record<string, any>
45
+ }
46
+
47
+ const rewritingFilters = () => {
48
+ scheme.value = tableData.value?.filters.map((filter: any) => {
49
+ return {
50
+ ...filter,
51
+ label: filter.name,
52
+ id: filter.name,
53
+ inline: true,
54
+ }
55
+ });
56
+ }
57
+
58
+ const updateColumns = () => {
59
+ let selectedColumns = tableData.value?.columns.filter((col: any) => baseColumns?.value?.includes(col.name));
60
+
61
+ columns.value = selectedColumns.map((col: any) => {
62
+ switch (col.name) {
63
+ case "title":
64
+ return {
65
+ ...col,
66
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '--',
67
+ icon: FileText,
68
+ };
69
+ case "slug":
70
+ return {
71
+ ...col,
72
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '-',
73
+ icon: Globe,
74
+ };
75
+ case "author":
76
+ return {
77
+ ...col,
78
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '-',
79
+ icon: User,
80
+ };
81
+ case "publish_at":
82
+ return {
83
+ ...col,
84
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '-',
85
+ icon: Calendar,
86
+ };
87
+ case "published_at":
88
+ return {
89
+ ...col,
90
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '-',
91
+ icon: Calendar,
92
+ };
93
+ default:
94
+ return {
95
+ ...col,
96
+ title: col.name ? col.name.charAt(0).toUpperCase() + col.name.slice(1) : '-',
97
+ };
98
+ }
99
+ });
100
+
101
+ showColumns.value = false;
102
+ };
103
+
104
+ const titleOfCollection = computed(
105
+ () => {
106
+ return menu.value.find((item: any) => item.id === route.params.id)?.title
107
+ }
108
+ );
109
+
110
+ const breadcrumbItems = computed(() => {
111
+ const collectionTitle = titleOfCollection.value || route.params.id;
112
+ return [
113
+ {
114
+ label: collectionTitle,
115
+ route: `collections/${route.params.id}`,
116
+ },
117
+ ];
118
+ });
119
+
120
+ const handleBreadcrumbNavigate = (routePath: string) => {
121
+ if (routePath === 'collections') {
122
+ router.push('/collections');
123
+ } else {
124
+ router.push(`/${routePath}`);
125
+ }
126
+ };
127
+
128
+ const handleCreateArticle = () => {
129
+ router.push(`/collections/${route.params.id}/create`);
130
+ };
131
+
132
+ const handleRemoveArticle = async (article: Article) => {
133
+ console.log('handleRemoveArticle');
134
+ confirm({
135
+ title: t("builder.deleteTitle"),
136
+ message: t("builder.deleteObject"),
137
+ type:'error',
138
+ onConfirm: async () => {
139
+ await fetch(`/api/cms/${route.params.id}/${article.id}`, { method: 'DELETE' });
140
+ fetchArticles();
141
+ }
142
+ });
143
+ };
144
+
145
+ const handleEditArticle = (article: Article) => {
146
+ router.push(`/collections/${route.params.id}/${article.id}`);
147
+ };
148
+
149
+ const fetchArticles = async () => {
150
+ if (isLoading.value) return; // Запобігаємо подвійним викликам
151
+
152
+ isLoading.value = true;
153
+ try {
154
+ const res = await fetch(
155
+ `/api/cms/${route.params.id}?page=${page.value}&filter=${filter.value}`
156
+ );
157
+ const data = await res.json();
158
+
159
+ articles.value = data.rows || [];
160
+ tableData.value = data;
161
+
162
+ // Clean filter schema to only include valid IFilterItem properties
163
+ const cleanFilters = (data.filters || []).map((filter: any) => {
164
+ const { extra, title, ...cleanFilter } = filter;
165
+ return cleanFilter;
166
+ });
167
+ scheme.value = cleanFilters;
168
+
169
+ if(route.params.id === 'pages') {
170
+ baseColumns.value.push('type');
171
+ }
172
+
173
+ setOptions();
174
+ updateColumns();
175
+ // rewritingFilters();
176
+ } finally {
177
+ isLoading.value = false;
178
+ }
179
+ };
180
+
181
+ const setOptions = () => {
182
+ if (!tableData.value?.columns) return;
183
+
184
+ const rawOptions = tableData.value.columns.map((col, index) => ({
185
+ text: col.label,
186
+ id: col.name,
187
+ }));
188
+
189
+
190
+ options.value = [
191
+ ...baseColumns.value
192
+ .map(col => rawOptions.find(opt => opt.id === col))
193
+ .filter(Boolean),
194
+ ...rawOptions.filter(opt => !baseColumns.value.includes(opt.id))
195
+ ];
196
+ }
197
+
198
+ const filterFn = (row: any, query: string) => {
199
+ if (!query) return true;
200
+ const q = query.toLowerCase();
201
+ return columns.value.some((col: any) => {
202
+ const val = row[col.name];
203
+ return val && val.toString().toLowerCase().includes(q);
204
+ });
205
+ };
206
+
207
+ const filterChange = (filters: FilterData) => {
208
+ const filterStr = Object.entries(filters?.data)
209
+ .filter(([, v]) => v != null)
210
+ .map(([key, val]) => `${key}=${val}`)
211
+ .join('|');
212
+
213
+ filter.value = filterStr;
214
+
215
+ // Оновлюємо URL з параметрами фільтрів
216
+ const query = { ...route.query };
217
+ if (filterStr) {
218
+ query.filter = filterStr;
219
+ } else {
220
+ delete query.filter;
221
+ }
222
+
223
+ router.replace({ query });
224
+ // fetchArticles() буде викликано через watch(() => route.query, ...)
225
+ };
226
+
227
+ // Функція для ініціалізації фільтрів з URL
228
+ const initializeFiltersFromUrl = () => {
229
+ if (route.query.filter) {
230
+ filter.value = route.query.filter as string;
231
+
232
+ // Парсимо фільтри з URL для початкових значень
233
+ const filterParams = (route.query.filter as string).split('|');
234
+ const parsedFilters: Record<string, any> = {};
235
+
236
+ filterParams.forEach(param => {
237
+ const [key, value] = param.split('=');
238
+ if (key && value) {
239
+ parsedFilters[key] = value;
240
+ }
241
+ });
242
+
243
+ initialFilters.value = parsedFilters;
244
+ } else {
245
+ // Очищаємо фільтри, якщо в URL немає параметрів
246
+ filter.value = '';
247
+ initialFilters.value = {};
248
+ // Примусово перерендеруємо компонент фільтрів
249
+ filterKey.value++;
250
+ }
251
+ };
252
+
253
+ onMounted(() => {
254
+ initializeFiltersFromUrl();
255
+ fetchArticles();
256
+ });
257
+
258
+ watch(() => route.params.id, () => {
259
+ // Reset filters when collection changes
260
+ initialFilters.value = {};
261
+ filter.value = '';
262
+ scheme.value = [];
263
+
264
+ // Clear URL filter parameters when switching collections
265
+ const query = { ...route.query };
266
+ delete query.filter;
267
+ router.replace({ query });
268
+
269
+ // Примусово перерендеруємо компонент фільтрів
270
+ filterKey.value++;
271
+
272
+ initializeFiltersFromUrl();
273
+ fetchArticles();
274
+ });
275
+
276
+ // Відстежуємо зміни в query параметрах для обробки навігації назад/вперед
277
+ watch(() => route.query, async () => {
278
+ initializeFiltersFromUrl();
279
+ await nextTick(); // Чекаємо, поки всі зміни застосуються
280
+ fetchArticles();
281
+ }, { deep: true });
282
+
283
+ watch(page, async () => {
284
+ // При зміні сторінки перевіряємо, чи є фільтри в URL
285
+ if (!route.query.filter) {
286
+ filter.value = '';
287
+ initialFilters.value = {};
288
+ // Примусово перерендеруємо компонент фільтрів
289
+ filterKey.value++;
290
+ }
291
+ await nextTick(); // Чекаємо, поки всі зміни застосуються
292
+ fetchArticles();
293
+ });
294
+
295
+ // useHead({
296
+ // title: () => t("articles.title") + " | CMS",
297
+ // });
298
+ </script>
299
+
300
+ <template>
301
+ <div class="space-y-6 max-w-7xl mx-auto">
302
+ <CollectionsBreadcrumb
303
+ :items="breadcrumbItems"
304
+ @navigate="handleBreadcrumbNavigate"
305
+ />
306
+ <div
307
+ class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
308
+ >
309
+ <div class="flex items-center gap-2">
310
+ <h1 class="text-3xl font-bold text-slate-800 dark:text-white mb-2">
311
+ {{ titleOfCollection }}
312
+ </h1>
313
+ <a :href="`https://cms.opengis.info/${locale}/guides/content/`" target="_blank" :title="$t('guide.content')">
314
+ <HelpCircle class="w-5 h-5" />
315
+ </a>
316
+ </div>
317
+ <button v-if="articles.length"
318
+ @click="handleCreateArticle"
319
+ 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
320
+ hover:bg-blue-700 hover:shadow-lg hover:scale-105
321
+ focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
322
+ disabled:pointer-events-none disabled:opacity-50"
323
+ >
324
+ <Plus class="w-4 h-4 mr-2" />
325
+ {{ $t("articles.createArticle") }}
326
+ </button>
327
+ </div>
328
+
329
+ <div v-if="articles.length || filter.length"
330
+ class="text-card-foreground shadow-lg border-0 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm"
331
+ >
332
+ <div v-if="true" class="p-2 border-b border-gray-200 sm:px-4 sm:py-2 dark:border-gray-700">
333
+ <div class="flex flex-col gap-4 sm:flex-row">
334
+ <VsFilter
335
+ :key="filterKey"
336
+ :schema="scheme"
337
+ :history="true"
338
+ :value="initialFilters"
339
+ view="inline"
340
+ @change="filterChange"
341
+ />
342
+
343
+ <div class="relative flex items-center gap-2 ml-auto">
344
+ <div class="flex items-center gap-2 cursor-pointer text-sm border border-gray-300 rounded-md p-2 dark:text-white" @click="showColumns = !showColumns" v-if="options.length > 0">
345
+ <p>{{ $t("articles.showColumns") }}</p>
346
+ <ChevronDown class="w-4 h-4" />
347
+ </div>
348
+ <div class="absolute top-full right-1/2 translate-x-1/2 min-w-full p-4 z-10 bg-white border border-gray-300 rounded-md mt-2 dark:bg-slate-800 dark:border-slate-700" v-if="options.length > 0 && showColumns">
349
+ <div class="max-h-60 overflow-y-auto overflow-x-hidden">
350
+ <VsInputCheckbox
351
+ v-if="options.length > 0 && showColumns"
352
+ :options="options"
353
+ v-model="baseColumns"
354
+ />
355
+ </div>
356
+ <button class="text-sm bg-blue-600 text-white rounded-md p-2 mt-4 w-full" @click="updateColumns">
357
+ {{ $t("articles.update") }}
358
+ </button>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ <UniversalTable
364
+ v-if="columns.length > 0"
365
+ class="max-h-[calc(100vh-270px)] overflow-y-auto"
366
+ :rows="articles"
367
+ :columns="columns"
368
+ :query="searchQuery"
369
+ :filterFn="filterFn"
370
+ @delete="handleRemoveArticle"
371
+ @edit="handleEditArticle"
372
+ />
373
+ <UniversalTablePagination
374
+ v-if="tableData?.total > 20"
375
+ :total="tableData?.filtered"
376
+ :count="tableData?.count"
377
+ v-model:page="page"
378
+ :limit="20"
379
+ />
380
+ </div>
381
+ <div v-else>
382
+ <EmptyData @action="handleCreateArticle"/>
383
+ </div>
384
+ </div>
385
+ </template>
@@ -0,0 +1,146 @@
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 class="flex gap-4">
5
+ <div>
6
+ <h1 class="text-3xl font-bold text-slate-800 dark:text-slate-100 mb-2">
7
+ {{ $t("navigation.collections") }}
8
+ </h1>
9
+ <p class="text-slate-600 dark:text-slate-300">
10
+ {{ $t("collections.selectCollection") }}
11
+ </p>
12
+ </div>
13
+ <a
14
+ :href="`https://cms.opengis.info/${locale}/guides/collections/`"
15
+ target="_blank"
16
+ :title="$t('guide.collections')"
17
+ class="mt-2"
18
+ >
19
+ <HelpCircle class="w-5 h-5" />
20
+ </a>
21
+ </div>
22
+
23
+ <div class="relative w-full max-w-sm">
24
+ <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
25
+ <Search class="w-5 h-5 text-gray-400" />
26
+ </div>
27
+ <input
28
+ v-model="searchQuery"
29
+ type="text"
30
+ class="block w-full py-2 pl-10 pr-3 leading-5 text-gray-900 placeholder-gray-500 transition-colors bg-white border border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:placeholder-gray-400 focus:outline-none focus:ring-sky-500 focus:border-sky-500 dark:text-white sm:text-sm"
31
+ :placeholder="$t('collections.searchCollections')"
32
+ />
33
+ </div>
34
+ </div>
35
+
36
+ <div v-if="collections.length > 0">
37
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
38
+ <button v-for="collection in filteredCollections" :key="collection.id" @click="handleViewCollection(collection)" class="w-full border border-gray-200 bg-white rounded-lg p-4 text-left hover:shadow-md transition-all hover:border-gray-300">
39
+ <div class="flex items-start justify-between">
40
+ <div class="flex items-start gap-3">
41
+ <File v-if="collection.id === 'pages'" class="shrink-0 lucide lucide-page w-5 h-5 text-blue-600" />
42
+ <Layers v-else class="shrink-0 lucide lucide-layers w-5 h-5 text-blue-600" />
43
+ <div>
44
+ <h4 class="text-sm font-semibold text-gray-800 mb-1">{{ collection.title }}</h4>
45
+ <p class="text-xs text-gray-600">{{ collection.description || 'Опис відсутній'}}</p>
46
+ </div>
47
+ </div>
48
+ <div class="flex items-center gap-2">
49
+ <span class="text-xs text-gray-500 whitespace-nowrap">{{ collection.entries }} записів</span>
50
+ <ChevronRight class="w-4 h-4 text-gray-400" />
51
+ </div>
52
+ </div>
53
+ </button>
54
+ </div>
55
+
56
+ </div>
57
+ <div v-else class="flex items-center justify-center min-h-[400px]">
58
+ <EmptyData />
59
+ </div>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { ref, computed, onMounted, inject } from "vue";
65
+ import { useRouter } from "vue-router";
66
+ import { HelpCircle, File, Layers, ChevronRight, Search } from "lucide-vue-next";
67
+ import { useI18n } from "vue-i18n";
68
+ import CollectionsGrid from "../../components/collections/CollectionsGrid.vue";
69
+ import EmptyData from "../../components/ui/EmptyData.vue";
70
+ import type { Collection } from "../../props/builder";
71
+ import { confirm } from "@opengis/core";
72
+
73
+ const { t, locale } = useI18n();
74
+ const router = useRouter();
75
+ const fetchContentTypes = inject<any>("fetchContentTypes");
76
+
77
+ const collections = ref<Collection[]>([]);
78
+ const searchQuery = ref("");
79
+
80
+ const filteredCollections = computed(() => {
81
+ // Фільтруємо тільки колекції (не single)
82
+ const onlyCollections = collections.value.filter(
83
+ (c) => c.type !== "single"
84
+ );
85
+
86
+ const q = searchQuery.value.trim().toLowerCase();
87
+ const searched = q
88
+ ? onlyCollections.filter((c: any) => {
89
+ const hay = [c?.title, c?.name, c?.id, c?.description]
90
+ .filter(Boolean)
91
+ .join(" ")
92
+ .toLowerCase();
93
+ return hay.includes(q);
94
+ })
95
+ : onlyCollections;
96
+
97
+ // Сортуємо: pages завжди перші
98
+ const sorted = [...searched].sort((a, b) => {
99
+ if (a.id === "pages") return -1;
100
+ if (b.id === "pages") return 1;
101
+ return 0;
102
+ });
103
+ return sorted;
104
+ });
105
+
106
+ async function fetchCollections() {
107
+ try {
108
+ const res = await fetch("/api/cms-type?type=collection");
109
+ if (!res.ok) throw new Error("Failed to fetch collections");
110
+ const data = await res.json();
111
+ collections.value = data.rows || [];
112
+ } catch (e) {
113
+ console.error("Error fetching collections:", e);
114
+ }
115
+ }
116
+
117
+ const handleEditCollection = (collection: Collection) => {
118
+ router.push(`/settings/collections/edit/${collection.id || collection.name}`);
119
+ };
120
+
121
+ const handleViewCollection = (collection: Collection) => {
122
+ router.push(`/collections/${collection.id}`);
123
+ };
124
+
125
+ const handleDeleteCollection = async (collection: Collection) => {
126
+ confirm({
127
+ title: t("builder.deleteTitle"),
128
+ message: t("builder.deleteObject"),
129
+ type: "error",
130
+ onConfirm: async () => {
131
+ try {
132
+ await fetch(`/api/cms-type/${collection.id}`, { method: "DELETE" });
133
+ fetchCollections();
134
+ if (fetchContentTypes) fetchContentTypes();
135
+ } catch (error) {
136
+ console.error("Error deleting collection:", error);
137
+ }
138
+ },
139
+ });
140
+ };
141
+
142
+ onMounted(() => {
143
+ fetchCollections();
144
+ });
145
+ </script>
146
+
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <div class="space-y-6 max-w-7xl mx-auto">
3
+ <div
4
+ class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
5
+ >
6
+ <h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
7
+ {{ $t("singletons.title") }}
8
+ </h1>
9
+ </div>
10
+ <div
11
+ class="overflow-hidden bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700"
12
+ >
13
+ <div class="p-4 border-b border-gray-200 sm:p-6 dark:border-gray-700">
14
+ <div class="flex flex-col gap-4 sm:flex-row">
15
+ <div class="relative flex-1">
16
+ <div
17
+ class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"
18
+ >
19
+ <Search class="w-5 h-5 text-gray-400" />
20
+ </div>
21
+ <input
22
+ type="text"
23
+ class="block w-full py-2 pl-10 pr-3 leading-5 text-gray-900 placeholder-gray-500 transition-colors bg-white border border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:placeholder-gray-400 focus:outline-none focus:ring-sky-500 focus:border-sky-500 dark:text-white sm:text-sm"
24
+ :placeholder="$t('singletons.search')"
25
+ v-model="searchQuery"
26
+ />
27
+ </div>
28
+
29
+ <div class="flex space-x-2">
30
+ <button
31
+ class="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition-colors bg-white border border-gray-300 rounded-md shadow-sm dark:border-gray-600 dark:text-gray-200 dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
32
+ >
33
+ <Filter class="w-4 h-4 mr-2 text-gray-500 dark:text-gray-400" />
34
+ {{ $t("common.actions.filter") }}
35
+ </button>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <UniversalTable
41
+ class="max-h-[calc(100vh-270px)] overflow-y-auto"
42
+ :rows="singletons"
43
+ :columns="columns"
44
+ :query="searchQuery"
45
+ :filterFn="filterFn"
46
+ @edit="handleEditSingleton"
47
+ />
48
+ <UniversalTablePagination
49
+ v-if="tableData?.total > 16"
50
+ :total="tableData?.total"
51
+ :count="tableData?.count || tableData?.total"
52
+ v-model:page="page"
53
+ />
54
+ </div>
55
+ </div>
56
+ </template>
57
+
58
+ <script setup lang="ts">
59
+ import { ref, onMounted, watch } from "vue";
60
+ import { useRouter } from "vue-router";
61
+ import { Plus, Search, Filter } from "lucide-vue-next";
62
+ import UniversalTable from "../../components/ui/UniversalTable.vue";
63
+ import UniversalTablePagination from "../../components/ui/UniversalTablePagination.vue";
64
+ import { useI18n } from "vue-i18n";
65
+
66
+ const { t } = useI18n();
67
+
68
+ interface Singleton {
69
+ id: string;
70
+ name: string;
71
+ description: string;
72
+ status: "published" | "draft";
73
+ lastModified: string;
74
+ }
75
+
76
+ const router = useRouter();
77
+ const searchQuery = ref("");
78
+ const singletons = ref<any[]>([]);
79
+ const page = ref(1);
80
+ const tableData = ref<any>(null);
81
+ const limit = ref(16);
82
+
83
+ const columns = [
84
+ {
85
+ name: "title",
86
+ title: t("common.title"),
87
+ type: "text",
88
+ },
89
+ ];
90
+
91
+ const handleCreateSingleton = () => {
92
+ router.push("/singletons/create");
93
+ };
94
+
95
+ const handleEditSingleton = (singleton: Singleton) => {
96
+ router.push(`/collections/single/${singleton.name || singleton.id}`);
97
+ };
98
+
99
+ const fetchSingletons = async () => {
100
+ const res = await fetch(`/api/cms?page=${page.value}&limit=${limit.value}`);
101
+ const data = await res.json();
102
+ const rows = data.rows;
103
+ singletons.value = rows?.filter((row: any) => row.type === "single");
104
+ tableData.value = data;
105
+ };
106
+
107
+ const filterFn = (row: any, query: string) => {
108
+ if (!query) return true;
109
+ const q = query.toLowerCase();
110
+ return columns.value.some((col: any) => {
111
+ const val = row[col.name];
112
+ return val && val.toString().toLowerCase().includes(q);
113
+ });
114
+ };
115
+
116
+ watch(page, fetchSingletons);
117
+
118
+ onMounted(fetchSingletons);
119
+ </script>