@open-commune/web 0.1.3

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 (132) hide show
  1. package/README.md +41 -0
  2. package/app.config.ts +10 -0
  3. package/app.vue +13 -0
  4. package/assets/css/main.css +13 -0
  5. package/components/data/display/row/data-avatar.vue +23 -0
  6. package/components/data/display/row/data-badge.vue +34 -0
  7. package/components/data/display/row/data-color.vue +17 -0
  8. package/components/data/display/row/data-display-badge-publish-status.vue +29 -0
  9. package/components/data/display/row/data-label.vue +37 -0
  10. package/components/data/display/row/data-last-update.vue +45 -0
  11. package/components/data/display/row/data-spacer.vue +4 -0
  12. package/components/data/display/row/data-summary.vue +44 -0
  13. package/components/data/display/row/data-text.vue +14 -0
  14. package/components/data/display/row/data-toggle.vue +16 -0
  15. package/components/data/display/row.vue +111 -0
  16. package/components/data/lists/data-list.vue +85 -0
  17. package/components/data/lists/data-lists-table.vue +155 -0
  18. package/components/elements/avatars/avatar-circular.vue +68 -0
  19. package/components/elements/avatars/avatar-with-text.vue +47 -0
  20. package/components/elements/badges/badge.vue +85 -0
  21. package/components/elements/buttons/primary.vue +32 -0
  22. package/components/elements/buttons/secondary.vue +35 -0
  23. package/components/elements/buttons/tertiary.vue +25 -0
  24. package/components/elements/dropdowns/dots.vue +75 -0
  25. package/components/elements/media/image.vue +82 -0
  26. package/components/elements/notifications/default.vue +90 -0
  27. package/components/forms/handlers/handler-collections.vue +189 -0
  28. package/components/forms/handlers/handler-publication-status.vue +193 -0
  29. package/components/forms/handlers/handler-records.vue +138 -0
  30. package/components/forms/handlers/handler-roles.vue +176 -0
  31. package/components/forms/handlers/handler-schedules.vue +218 -0
  32. package/components/forms/handlers/handler-steps.vue +147 -0
  33. package/components/forms/handlers/handler-tokens.vue +79 -0
  34. package/components/forms/inputs/input-color-picker.vue +159 -0
  35. package/components/forms/inputs/input-editor.vue +363 -0
  36. package/components/forms/inputs/input-files-media.vue +150 -0
  37. package/components/forms/inputs/input-files.vue +98 -0
  38. package/components/forms/inputs/input-json.vue +65 -0
  39. package/components/forms/inputs/input-location.vue +179 -0
  40. package/components/forms/inputs/input-text-area.vue +65 -0
  41. package/components/forms/inputs/input-text.vue +122 -0
  42. package/components/forms/inputs/input-toggle.vue +125 -0
  43. package/components/forms/inputs/media-section/media-editor.vue +95 -0
  44. package/components/forms/layouts/form.vue +203 -0
  45. package/components/forms/layouts/layout.vue +47 -0
  46. package/components/forms/layouts/modal.vue +96 -0
  47. package/components/forms/layouts/navigation.vue +48 -0
  48. package/components/forms/layouts/section.vue +118 -0
  49. package/components/forms/selects/select-collection-enum.vue +195 -0
  50. package/components/forms/selects/select-date.vue +245 -0
  51. package/components/forms/selects/select-enum.vue +186 -0
  52. package/components/forms/selects/select-record.vue +274 -0
  53. package/components/layout/headings/headings-collection.vue +29 -0
  54. package/components/layout/headings/headings-record.vue +33 -0
  55. package/components/layout/navigations/primary/dropdown-publish.vue +147 -0
  56. package/components/layout/navigations/primary.vue +132 -0
  57. package/components/layout/notifications/notifications-list.vue +19 -0
  58. package/composables/core/domains/use-definitions.ts +57 -0
  59. package/composables/core/domains/use-entries.ts +62 -0
  60. package/composables/core/domains/use-media.ts +82 -0
  61. package/composables/core/use-core.ts +12 -0
  62. package/composables/use-ability.js +38 -0
  63. package/composables/use-models.js +27 -0
  64. package/composables/use-session.ts +80 -0
  65. package/composables/use-timer.js +62 -0
  66. package/composables/use-trpc.js +17 -0
  67. package/editor/index.ts +44 -0
  68. package/editor/views/CalloutView.vue +22 -0
  69. package/editor/views/DetailsContentView.vue +11 -0
  70. package/editor/views/DetailsSummaryView.vue +11 -0
  71. package/editor/views/DetailsView.vue +72 -0
  72. package/editor/views/EmbedView.vue +53 -0
  73. package/editor/views/FileView.vue +79 -0
  74. package/editor/views/ImageView.vue +56 -0
  75. package/editor/views/UploadView.vue +71 -0
  76. package/layouts/default.vue +21 -0
  77. package/layouts/entrance.vue +10 -0
  78. package/layouts/record.vue +21 -0
  79. package/middleware/00.auth.global.js +16 -0
  80. package/middleware/01.instance.global.js +21 -0
  81. package/middleware/03.verify-permissions.global.js +11 -0
  82. package/middleware/04.prefetch-page.global.js +18 -0
  83. package/middleware/05.headings-page.global.js +25 -0
  84. package/models/_commun.js +243 -0
  85. package/models/_factory.js +34 -0
  86. package/models/collection.js +87 -0
  87. package/models/entrance.js +42 -0
  88. package/models/media.js +82 -0
  89. package/models/organization.js +92 -0
  90. package/models/record.js +165 -0
  91. package/models/role.js +66 -0
  92. package/models/token.js +56 -0
  93. package/models/user.js +97 -0
  94. package/nuxt.config.ts +151 -0
  95. package/package.json +71 -0
  96. package/pages/[collection]/[record]/index.vue +88 -0
  97. package/pages/[collection]/index.vue +55 -0
  98. package/pages/_lab/editor.vue +149 -0
  99. package/pages/auth/callback.vue +31 -0
  100. package/pages/index/index.vue +85 -0
  101. package/pages/index/password/define/[token].vue +79 -0
  102. package/pages/index/password/recover/index.vue +72 -0
  103. package/pages/index/welcome/[token].vue +90 -0
  104. package/pages/index.vue +44 -0
  105. package/pages/management/index.vue +15 -0
  106. package/pages/management/members.vue +176 -0
  107. package/pages/management/organizations.vue +192 -0
  108. package/pages/management/roles.vue +183 -0
  109. package/pages/overview.vue +15 -0
  110. package/pages/settings.vue +197 -0
  111. package/plugins/01.session.client.ts +5 -0
  112. package/plugins/02.vue-query.ts +25 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/logo-black.svg +1 -0
  115. package/public/logo-white.svg +1 -0
  116. package/server/api/trpc/[...trpc].ts +28 -0
  117. package/server/api/v1/content/deployment.get.ts +45 -0
  118. package/server/api/v1/content/records.get.ts +12 -0
  119. package/server/middleware/0.security.ts +19 -0
  120. package/server/middleware/1.cors.ts +31 -0
  121. package/server/middleware/2.session.ts +20 -0
  122. package/server/middleware/3.api-token.ts +20 -0
  123. package/server/plugins/core.ts +8 -0
  124. package/server/routes/_tasks/[name].post.ts +20 -0
  125. package/server/routes/health.get.ts +6 -0
  126. package/server/tasks/deploy.ts +26 -0
  127. package/server/types.d.ts +8 -0
  128. package/server/utils/core.ts +18 -0
  129. package/store/layout/headings.js +21 -0
  130. package/store/layout/modal.js +80 -0
  131. package/store/layout/notifications.js +52 -0
  132. package/store/layout/workspace.js +80 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @open-commune/web
2
+
3
+ The [Open Commune](https://github.com/open-commune/open-commune) application, published as a **Nuxt layer**: the admin screens, the public API routes, and the tRPC handler that binds them to [`@open-commune/core`](https://www.npmjs.com/package/@open-commune/core).
4
+
5
+ It is shipped as sources, not as a bundle — a Nuxt layer is compiled by the application that extends it, and that is what lets you override any page, component or route by placing a file of the same name in your own project.
6
+
7
+ ## Use it as it is
8
+
9
+ The published Docker image runs this layer unmodified. See the [repository](https://github.com/open-commune/open-commune) for self-hosting.
10
+
11
+ ## Extend it
12
+
13
+ ```bash
14
+ npm install @open-commune/web
15
+ ```
16
+
17
+ ```ts
18
+ // nuxt.config.ts
19
+ export default defineNuxtConfig({
20
+ extends: ['@open-commune/web'],
21
+ });
22
+ ```
23
+
24
+ Your project then holds only what it adds. Anything you place at the same path wins over the layer:
25
+
26
+ - `pages/…` — your own screens, alongside the ones the layer brings
27
+ - `server/api/…` — extra routes, served from the same origin
28
+ - `server/tasks/…` and `nitro.scheduledTasks` — scheduled work
29
+ - `components/…` — override a component by name
30
+
31
+ Run it with Bun: the database layer uses `bun:sqlite`, so `bun --bun nuxt dev`, not `nuxt dev`.
32
+
33
+ > **Known upstream issue.** Nitro runs a layer's `scheduledTasks` twice ([nitrojs/nitro#4155](https://github.com/nitrojs/nitro/issues/4155)). Declare your cron entries in the top application, not in a layer.
34
+
35
+ ## Configuration
36
+
37
+ Environment is validated at startup by `@open-commune/core`; see `.env.example` in the repository for the full list.
38
+
39
+ ## Licence
40
+
41
+ AGPL-3.0-or-later.
package/app.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ export default defineAppConfig({
2
+ ui: {
3
+ colors: {
4
+ // Sober palette while the visual identity settles; final palette is a
5
+ // design decision of the screen refonte.
6
+ primary: 'neutral',
7
+ neutral: 'neutral',
8
+ },
9
+ },
10
+ })
package/app.vue ADDED
@@ -0,0 +1,13 @@
1
+ <template>
2
+ <UApp :locale="fr">
3
+ <NuxtLayout>
4
+ <NuxtPage />
5
+ </NuxtLayout>
6
+ </UApp>
7
+ </template>
8
+
9
+ <script setup>
10
+ // UApp provides the Nuxt UI overlay providers (tooltips, toasts) —
11
+ // components using them crash without it.
12
+ import { fr } from '@nuxt/ui/locale'
13
+ </script>
@@ -0,0 +1,13 @@
1
+ @import "tailwindcss";
2
+ @import "@nuxt/ui";
3
+
4
+ /* Legacy text sizes still referenced by pre-refonte screens; drop once every
5
+ screen has moved to Nuxt UI components. */
6
+ @theme {
7
+ --text-7xs: 0.125rem;
8
+ --text-6xs: 0.25rem;
9
+ --text-5xs: 0.375rem;
10
+ --text-4xs: 0.5rem;
11
+ --text-3xs: 0.625rem;
12
+ --text-2xs: 0.75rem;
13
+ }
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <avatar-circular
3
+ :place-holder="name"
4
+ :media="media"
5
+ :organization="organization"
6
+ size="sm"
7
+ />
8
+ </template>
9
+
10
+ <script setup>
11
+ import avatarCircular from '~/components/elements/avatars/avatar-circular'
12
+
13
+ const $props = defineProps({
14
+ value: {
15
+ type: Object,
16
+ default: () => ({})
17
+ }
18
+ })
19
+
20
+ const organization = computed(() => $props.value._id)
21
+ const name = computed(() => $props.value.name)
22
+ const media = computed(() => $props.value.logo)
23
+ </script>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <div>
3
+ <badge
4
+ :title="badgeTitle"
5
+ :color="badgeColor"
6
+ :dot="false"
7
+ :border="true"
8
+ @click="$emitters('click')"
9
+ />
10
+ </div>
11
+ </template>
12
+
13
+ <script setup>
14
+ import badge from '~/components/elements/badges/badge'
15
+
16
+ // Prepare emitters
17
+ const $emitters = defineEmits(['click'])
18
+
19
+ // Prepare props
20
+ const $props = defineProps({
21
+ color: {
22
+ type: String,
23
+ default: '#e5e7eb'
24
+ },
25
+ value: {
26
+ type: [String, Object],
27
+ default: ''
28
+ }
29
+ })
30
+
31
+ // Prepare computed
32
+ const badgeTitle = computed(() => typeof $props.value === 'object' ? $props.value.name : $props.value)
33
+ const badgeColor = computed(() => typeof $props.value === 'object' ? $props.value.color :$props.color)
34
+ </script>
@@ -0,0 +1,17 @@
1
+ <template>
2
+ <div class="h-5 w-5 rounded-full max-w-xs" :style="`background-color: ${value}`" @click="$emit('click')">
3
+ &#8203;
4
+ </div>
5
+ </template>
6
+
7
+ <script>
8
+ export default {
9
+ name: 'DataColor',
10
+ props: {
11
+ value: {
12
+ type: String,
13
+ default: ''
14
+ }
15
+ }
16
+ }
17
+ </script>
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <badge :title="badgeTitle" :color="badgeColor" border dot />
3
+ </template>
4
+
5
+ <script setup>
6
+ import badge from '~/components/elements/badges/badge'
7
+
8
+ // Prepare constants
9
+ const PUBLICATION_STATUSES = [
10
+ { label: 'Brouillon', color: '#e5e7eb', option: 'draft' },
11
+ { label: 'A valider', color: '#facc15', option: 'waiting' },
12
+ { label: 'Prêt', color: '#60a5fa', option: 'ready' },
13
+ { label: 'Programmé', option: 'scheduled' },
14
+ { label: 'Publié', color: '#34d399', option: 'published' }
15
+ ]
16
+
17
+ // Prepare props
18
+ const $props = defineProps({
19
+ status: {
20
+ type: String,
21
+ default: ''
22
+ }
23
+ })
24
+
25
+ // Prepare computed
26
+ const correspondingStatus = computed(() => PUBLICATION_STATUSES.find(({ option }) => option === $props.status))
27
+ const badgeTitle = computed(() => correspondingStatus.value?.label)
28
+ const badgeColor = computed(() => correspondingStatus.value?.color)
29
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div>
3
+ <badge
4
+ v-for="({ _id, title, color, icon }) in records"
5
+ :key="_id"
6
+ :title="title"
7
+ :color="color"
8
+ :icon="icon"
9
+ @click="$emitters('click', title)"
10
+ />
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ import badge from '~/components/elements/badges/badge'
16
+
17
+ const { Record } = useModels()
18
+
19
+ // Prepare emitters
20
+ const $emitters = defineEmits(['click'])
21
+
22
+ // Prepare props
23
+ const $props = defineProps({
24
+ value: {
25
+ type: [String, Array],
26
+ default: () => []
27
+ }
28
+ })
29
+
30
+ // Prepare computed
31
+ const records = computed(() => {
32
+ if (!Array.isArray($props.value)) {
33
+ return [Record.onRetrieve(Record.repo.find($props.value))]
34
+ }
35
+ return Record.repo.find($props.value).map(Record.onRetrieve)
36
+ })
37
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <div class="text-right space-y-1">
3
+ <div v-if="inline" class="inline-flex items-center">
4
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
5
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
6
+ </svg>
7
+ <span class="text-gray-500 text-sm">
8
+ Modifié {{ updatedAtFromNow }}, par
9
+ </span>
10
+ <avatar-with-text :user="updatedBy" prefix="@" no-avatar disabled />
11
+ </div>
12
+ <div v-else>
13
+ <avatar-with-text :user="updatedBy" disabled class="space-x-2" />
14
+ <div class="flex items-center justify-end space-x-1">
15
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
16
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
17
+ </svg>
18
+ <span class="text-gray-500 text-xs">
19
+ {{ updatedAtFromNow }}
20
+ </span>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ </template>
25
+
26
+ <script setup>
27
+ import { DateTime } from 'luxon'
28
+ import avatarWithText from '~/components/elements/avatars/avatar-with-text'
29
+
30
+ // Prepare props
31
+ const $props = defineProps({
32
+ inline: {
33
+ type: Boolean,
34
+ default: false
35
+ },
36
+ value: {
37
+ type: Object,
38
+ default: () => ({})
39
+ }
40
+ })
41
+
42
+ // Prepare computed
43
+ const updatedBy = computed(() => $props.value?.updatedBy || {})
44
+ const updatedAtFromNow = computed(() => DateTime.fromISO($props.value?.updatedAt).setLocale('fr').toRelative())
45
+ </script>
@@ -0,0 +1,4 @@
1
+ <template>
2
+ <div class="w-full flex-grow">
3
+ </div>
4
+ </template>
@@ -0,0 +1,44 @@
1
+ <template>
2
+ <div class="flex flex-col space-y-1 py-1 items-start justify-center text-sm cursor-pointer" @click="$emit('click')">
3
+ <data-display-badge-publish-status :status="status" class="sm:hidden" />
4
+ <div class="flex items-center space-x-2">
5
+ <!-- max-w borné (upgrade-admin-nuxt4) : truncate = nowrap, un titre long
6
+ dictait la largeur max-content de la colonne (table-auto). -->
7
+ <p class="font-normal xs:w-60 truncate sm:min-w-0 sm:max-w-xl">
8
+ {{ title }}
9
+ </p>
10
+ <data-display-badge-publish-status :status="status" class="hidden sm:inline-flex" />
11
+ </div>
12
+ <!-- Bornée (upgrade-admin-nuxt4) : une description longue étirait la
13
+ colonne au-delà de l'écran (table-auto suit la largeur du contenu). -->
14
+ <p class="hidden sm:block font-light text-gray-500 max-w-xl line-clamp-2 break-words">
15
+ {{ description }}
16
+ </p>
17
+ </div>
18
+ </template>
19
+
20
+ <script setup>
21
+ import DataDisplayBadgePublishStatus from '~/components/data/display/row/data-display-badge-publish-status'
22
+ import badge from '~/components/elements/badges/badge'
23
+
24
+ // Prepare props
25
+ const $props = defineProps({
26
+ titleKey: {
27
+ type: String,
28
+ default: 'title'
29
+ },
30
+ descriptionKey: {
31
+ type: String,
32
+ default: 'description'
33
+ },
34
+ value: {
35
+ type: Object,
36
+ default: () => ({})
37
+ }
38
+ })
39
+
40
+ // Prepare computed
41
+ const title = computed(() => $props.value[$props.titleKey])
42
+ const description = computed(() => $props.value[$props.descriptionKey])
43
+ const status = computed(() => $props.value.status)
44
+ </script>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <div class="text-sm cursor-pointer max-w-sm" @click="$emit('click')">
3
+ {{ value }}
4
+ </div>
5
+ </template>
6
+
7
+ <script setup>
8
+ defineProps({
9
+ value: {
10
+ type: String,
11
+ default: ''
12
+ }
13
+ })
14
+ </script>
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <div class="inline-flex">
3
+ <input-toggle :value="value" @change="$emit('change', $event)" />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup>
8
+ import inputToggle from '~/components/forms/inputs/input-toggle'
9
+
10
+ defineProps({
11
+ value: {
12
+ type: Boolean,
13
+ default: false
14
+ }
15
+ })
16
+ </script>
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <li class="w-full inline-flex items-center justify-start hover:bg-gray-50 cursor-pointer" @click.self="_handleClick">
3
+ <div
4
+ v-for="({ property, component, componentOptions, nestedValue = true, alignRight = false, alignLeft = false, grow = false }) in headers"
5
+ :key="property"
6
+ :class="{
7
+ 'flex-grow': grow,
8
+ 'text-right': alignRight,
9
+ 'text-left': alignLeft
10
+ }"
11
+ class="px-4 py-1"
12
+ >
13
+ <data-avatar
14
+ v-if="component === 'data-avatar'"
15
+ v-bind="componentOptions"
16
+ :value="nestedValue ? item[property] : item"
17
+ @click="_handleClick"
18
+ />
19
+ <data-text
20
+ v-if="component === 'data-text'"
21
+ v-bind="componentOptions"
22
+ :value="nestedValue ? item[property] : item"
23
+ @click="_handleClick"
24
+ />
25
+ <data-badge
26
+ v-if="component === 'data-badge'"
27
+ v-bind="componentOptions"
28
+ :value="nestedValue ? item[property] : item"
29
+ @click="_handleClick"
30
+ />
31
+ <data-label
32
+ v-if="component === 'data-label'"
33
+ v-bind="componentOptions"
34
+ :value="nestedValue ? item[property] : item"
35
+ @click="_handleClick"
36
+ />
37
+ <data-spacer
38
+ v-if="component === 'data-spacer'"
39
+ v-bind="componentOptions"
40
+ />
41
+ <data-last-update
42
+ v-if="component === 'data-last-update'"
43
+ v-bind="componentOptions"
44
+ :value="nestedValue ? item[property] : item"
45
+ @click="_handleClick"
46
+ />
47
+ <!-- <data-color v-if="component === 'data-color'" v-bind="componentOptions" :value="nestedValue ? item[property] : item" class="hidden lg:block" /> -->
48
+ <data-summary
49
+ v-if="component === 'data-summary'"
50
+ v-bind="componentOptions"
51
+ :value="nestedValue ? item[property] : item"
52
+ @click="_handleClick"
53
+ />
54
+ <data-toggle
55
+ v-if="component === 'data-toggle'"
56
+ v-bind="componentOptions"
57
+ :value="nestedValue ? item[property] : item"
58
+ class="item-self-end"
59
+ @change="$emit(property, { ...item, [property]: $event })"
60
+ />
61
+ <dropdown-dots
62
+ v-else-if="component === 'actions'"
63
+ v-bind="componentOptions"
64
+ @action="$emit($event, item)"
65
+ />
66
+ </div>
67
+ </li>
68
+ </template>
69
+
70
+ <script setup>
71
+ import dataBadge from '~/components/data/display/row/data-badge'
72
+ // import dataColor from '~/components/data/display/row/data-color'
73
+ import dropdownDots from '~/components/elements/dropdowns/dots'
74
+ import dataLabel from '~/components/data/display/row/data-label'
75
+ import dataLastUpdate from '~/components/data/display/row/data-last-update'
76
+ import dataSummary from '~/components/data/display/row/data-summary'
77
+ import dataSpacer from '~/components/data/display/row/data-spacer'
78
+ import dataAvatar from '~/components/data/display/row/data-avatar'
79
+ import dataText from '~/components/data/display/row/data-text'
80
+ import dataToggle from '~/components/data/display/row/data-toggle'
81
+
82
+ // Prepare emit
83
+ const $router = useRouter()
84
+ const $emit = defineEmits(['click', 'update', 'remove', 'fetch-more', 'usage'])
85
+
86
+ // Prepare props
87
+ const $props = defineProps({
88
+ displayHeader: {
89
+ type: Boolean,
90
+ default: false
91
+ },
92
+ headers: {
93
+ type: Array,
94
+ default: () => []
95
+ },
96
+ item: {
97
+ type: Object,
98
+ default: () => ({})
99
+ },
100
+ to: {
101
+ type: String,
102
+ default: ''
103
+ }
104
+ })
105
+
106
+ // Prepare methods
107
+ const _handleClick = () => {
108
+ console.log('routing to', $props.to)
109
+ $props.to ? $router.replace($props.to) : $emit('click')
110
+ }
111
+ </script>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <ul :class="gridDensity" class="grid gap-2 w-full">
3
+ <li
4
+ v-for="({ children, ...item }, i) in groupedItems"
5
+ :key="`group-item-${item[itemValue]}`"
6
+ :class="{ 'col-span-full': children?.length }"
7
+ >
8
+ <!-- [list]item-by slot -->
9
+ <slot name="item" v-bind="{ item, isFirst: i === 0, isLast: i === groupedItems.length - 1 }">
10
+ {{ item }}
11
+ </slot>
12
+
13
+ <!-- [list]item-child slot -->
14
+ <ul v-if="children?.length" :class="gridDensityChidren" class="grid w-full">
15
+ <li v-for="(itemChild, j) in children" :key="`item-${itemChild[itemValue]}`">
16
+ <slot name="item-child" v-bind="{ item: itemChild, isFirst: j === 0, isLast: j === children.length - 1 }">
17
+ {{ itemChild }}
18
+ </slot>
19
+ </li>
20
+ </ul>
21
+ </li>
22
+ </ul>
23
+ </template>
24
+
25
+ <script setup>
26
+ // Prepare constants
27
+ const GRID_PRESETS = {
28
+ 1: 'grid-cols-1',
29
+ 2: 'grid-cols-1 sm:grid-cols-2',
30
+ 3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
31
+ 4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
32
+ 5: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
33
+ }
34
+
35
+ // Prepare props
36
+ const $props = defineProps({
37
+ items: {
38
+ type: Array,
39
+ default: () => []
40
+ },
41
+ itemKey: {
42
+ type: String,
43
+ default: ''
44
+ },
45
+ itemValue: {
46
+ type: String,
47
+ default: 'value'
48
+ },
49
+ groupBy: {
50
+ type: String,
51
+ default: ''
52
+ },
53
+ density: {
54
+ type: Number,
55
+ default: 5,
56
+ enums: [1, 2, 3, 4, 5]
57
+ },
58
+ densityChildren: {
59
+ type: Number,
60
+ default: 5,
61
+ enums: [1, 2, 3, 4, 5]
62
+ }
63
+ })
64
+
65
+ // Prepare computed
66
+ const gridDensity = computed(() => GRID_PRESETS[$props.density])
67
+ const gridDensityChidren = computed(() => GRID_PRESETS[$props.densityChildren])
68
+ const groupedItems = computed(() => {
69
+ if ($props.groupBy) {
70
+ const _items = new Map()
71
+ for (const item of $props.items) {
72
+ const group = item[$props.groupBy]
73
+ if (group) {
74
+ const parent = $props.items.find(({ [$props.itemValue]: check }) => check === group)
75
+ if (!_items.has(parent)) {
76
+ _items.set(parent, [])
77
+ }
78
+ _items.get(parent).push(item)
79
+ }
80
+ }
81
+ return Array.from(_items, ([parent, children]) => ({ ...parent, children }))
82
+ }
83
+ return $props.items
84
+ })
85
+ </script>