@motor-cms/ui-admin 1.0.1-alpha.0

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 (103) hide show
  1. package/README.md +77 -0
  2. package/app/components/form/inputs/CategoryTreeInput.vue +154 -0
  3. package/app/components/form/inputs/CategoryTreePicker.vue +355 -0
  4. package/app/components/form/inputs/NestedDraggable.vue +217 -0
  5. package/app/components/form/inputs/QuicklinksInput.vue +186 -0
  6. package/app/lang/de/motor-admin/CLAUDE.md +21 -0
  7. package/app/lang/de/motor-admin/ai_system_prompts.json +12 -0
  8. package/app/lang/de/motor-admin/categories.json +12 -0
  9. package/app/lang/de/motor-admin/category_trees.json +14 -0
  10. package/app/lang/de/motor-admin/clients.json +26 -0
  11. package/app/lang/de/motor-admin/config_variables.json +14 -0
  12. package/app/lang/de/motor-admin/domains.json +19 -0
  13. package/app/lang/de/motor-admin/email_templates.json +38 -0
  14. package/app/lang/de/motor-admin/global.json +5 -0
  15. package/app/lang/de/motor-admin/languages.json +16 -0
  16. package/app/lang/de/motor-admin/permissions.json +14 -0
  17. package/app/lang/de/motor-admin/roles.json +15 -0
  18. package/app/lang/de/motor-admin/users.json +22 -0
  19. package/app/lang/en/motor-admin/CLAUDE.md +7 -0
  20. package/app/lang/en/motor-admin/ai_system_prompts.json +12 -0
  21. package/app/lang/en/motor-admin/categories.json +12 -0
  22. package/app/lang/en/motor-admin/category_trees.json +14 -0
  23. package/app/lang/en/motor-admin/clients.json +26 -0
  24. package/app/lang/en/motor-admin/config_variables.json +14 -0
  25. package/app/lang/en/motor-admin/domains.json +18 -0
  26. package/app/lang/en/motor-admin/email_templates.json +33 -0
  27. package/app/lang/en/motor-admin/global.json +5 -0
  28. package/app/lang/en/motor-admin/languages.json +16 -0
  29. package/app/lang/en/motor-admin/permissions.json +14 -0
  30. package/app/lang/en/motor-admin/roles.json +15 -0
  31. package/app/lang/en/motor-admin/users.json +22 -0
  32. package/app/pages/dashboard.vue +5 -0
  33. package/app/pages/index.vue +39 -0
  34. package/app/pages/login.vue +85 -0
  35. package/app/pages/motor-admin/ai-system-prompts/CLAUDE.md +7 -0
  36. package/app/pages/motor-admin/ai-system-prompts/[id]/edit.vue +48 -0
  37. package/app/pages/motor-admin/ai-system-prompts/create.vue +40 -0
  38. package/app/pages/motor-admin/ai-system-prompts/index.vue +68 -0
  39. package/app/pages/motor-admin/category-trees/CLAUDE.md +7 -0
  40. package/app/pages/motor-admin/category-trees/[id]/CLAUDE.md +7 -0
  41. package/app/pages/motor-admin/category-trees/[id]/categories/[categoryId]/edit.vue +73 -0
  42. package/app/pages/motor-admin/category-trees/[id]/categories/create.vue +64 -0
  43. package/app/pages/motor-admin/category-trees/[id]/edit.vue +45 -0
  44. package/app/pages/motor-admin/category-trees/[id]/index.vue +81 -0
  45. package/app/pages/motor-admin/category-trees/create.vue +37 -0
  46. package/app/pages/motor-admin/category-trees/index.vue +54 -0
  47. package/app/pages/motor-admin/clients/CLAUDE.md +11 -0
  48. package/app/pages/motor-admin/clients/[id]/CLAUDE.md +11 -0
  49. package/app/pages/motor-admin/clients/[id]/edit.vue +45 -0
  50. package/app/pages/motor-admin/clients/create.vue +37 -0
  51. package/app/pages/motor-admin/clients/index.vue +46 -0
  52. package/app/pages/motor-admin/config-variables/CLAUDE.md +11 -0
  53. package/app/pages/motor-admin/config-variables/[id]/edit.vue +44 -0
  54. package/app/pages/motor-admin/config-variables/create.vue +36 -0
  55. package/app/pages/motor-admin/config-variables/index.vue +66 -0
  56. package/app/pages/motor-admin/domains/CLAUDE.md +11 -0
  57. package/app/pages/motor-admin/domains/[id]/edit.vue +54 -0
  58. package/app/pages/motor-admin/domains/create.vue +46 -0
  59. package/app/pages/motor-admin/domains/index.vue +98 -0
  60. package/app/pages/motor-admin/email-templates/CLAUDE.md +12 -0
  61. package/app/pages/motor-admin/email-templates/[id]/CLAUDE.md +7 -0
  62. package/app/pages/motor-admin/email-templates/[id]/edit.vue +56 -0
  63. package/app/pages/motor-admin/email-templates/create.vue +48 -0
  64. package/app/pages/motor-admin/email-templates/index.vue +67 -0
  65. package/app/pages/motor-admin/index.vue +12 -0
  66. package/app/pages/motor-admin/languages/CLAUDE.md +7 -0
  67. package/app/pages/motor-admin/languages/[id]/edit.vue +44 -0
  68. package/app/pages/motor-admin/languages/create.vue +36 -0
  69. package/app/pages/motor-admin/languages/index.vue +44 -0
  70. package/app/pages/motor-admin/permission-groups/CLAUDE.md +14 -0
  71. package/app/pages/motor-admin/permission-groups/[id]/CLAUDE.md +11 -0
  72. package/app/pages/motor-admin/permission-groups/[id]/edit.vue +49 -0
  73. package/app/pages/motor-admin/permission-groups/create.vue +41 -0
  74. package/app/pages/motor-admin/permission-groups/index.vue +43 -0
  75. package/app/pages/motor-admin/roles/CLAUDE.md +7 -0
  76. package/app/pages/motor-admin/roles/[id]/edit.vue +47 -0
  77. package/app/pages/motor-admin/roles/create.vue +40 -0
  78. package/app/pages/motor-admin/roles/index.vue +45 -0
  79. package/app/pages/motor-admin/theme-preview/CLAUDE.md +7 -0
  80. package/app/pages/motor-admin/theme-preview/index.vue +4801 -0
  81. package/app/pages/motor-admin/theme-preview/themes/CLAUDE.md +11 -0
  82. package/app/pages/motor-admin/theme-preview/themes/asymmetric-brutalist.md +381 -0
  83. package/app/pages/motor-admin/theme-preview/themes/bold-modern.md +231 -0
  84. package/app/pages/motor-admin/theme-preview/themes/geometric-minimal.md +778 -0
  85. package/app/pages/motor-admin/theme-preview/themes/gradient-flow.md +1057 -0
  86. package/app/pages/motor-admin/theme-preview/themes/liquid-glass.md +823 -0
  87. package/app/pages/motor-admin/theme-preview/themes/neon-amber.md +1223 -0
  88. package/app/pages/motor-admin/theme-preview/themes/neon-terminal.md +779 -0
  89. package/app/pages/motor-admin/theme-preview/themes/neon-violet.md +1134 -0
  90. package/app/pages/motor-admin/theme-preview/themes/professional-clean.md +232 -0
  91. package/app/pages/motor-admin/theme-preview/themes/refined-brutalist.md +462 -0
  92. package/app/pages/motor-admin/theme-preview/themes/wild-card.md +263 -0
  93. package/app/pages/motor-admin/users/CLAUDE.md +17 -0
  94. package/app/pages/motor-admin/users/[id]/CLAUDE.md +11 -0
  95. package/app/pages/motor-admin/users/[id]/edit.vue +83 -0
  96. package/app/pages/motor-admin/users/create.vue +40 -0
  97. package/app/pages/motor-admin/users/index.vue +66 -0
  98. package/app/pages/profile.vue +363 -0
  99. package/app/pages/search.vue +91 -0
  100. package/app/types/generated/form-meta.ts +258 -0
  101. package/app/types/generated/grid-meta.ts +172 -0
  102. package/nuxt.config.ts +1 -0
  103. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # motor-ui-admin
2
+
3
+ Admin UI layer for the Energis CMS. Provides CRUD pages for all core admin entities, plus the reusable form and grid component systems that power them.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ motor-ui-admin/
9
+ ├── app/
10
+ │ ├── components/
11
+ │ │ └── form/inputs/ # Admin-specific form inputs
12
+ │ │ ├── NestedDraggable.vue # Tree reordering drag-and-drop
13
+ │ │ ├── QuicklinksInput.vue # Quicklinks entity input
14
+ │ │ ├── CategoryTreeInput.vue # Category tree selector
15
+ │ │ └── CategoryTreePicker.vue # Category tree picker with drag-and-drop
16
+ │ ├── lang/
17
+ │ │ ├── de/motor-admin/ # German translations (13 modules)
18
+ │ │ └── en/motor-admin/ # English translations (13 modules)
19
+ │ ├── pages/
20
+ │ │ ├── index.vue # Root redirect
21
+ │ │ ├── login.vue # Login page
22
+ │ │ ├── dashboard.vue # Dashboard
23
+ │ │ ├── profile.vue # User profile
24
+ │ │ ├── search.vue # Global search
25
+ │ │ └── motor-admin/
26
+ │ │ ├── users/ # Users CRUD
27
+ │ │ ├── roles/ # Roles CRUD
28
+ │ │ ├── permission-groups/ # Permission groups CRUD
29
+ │ │ ├── clients/ # Clients CRUD
30
+ │ │ ├── domains/ # Domains CRUD
31
+ │ │ ├── languages/ # Languages CRUD
32
+ │ │ ├── config-variables/ # Config variables CRUD
33
+ │ │ ├── email-templates/ # Email templates CRUD
34
+ │ │ ├── ai-system-prompts/ # AI system prompts CRUD
35
+ │ │ ├── category-trees/ # Category trees + nested categories CRUD
36
+ │ │ └── theme-preview/ # Theme preview tool
37
+ │ └── types/generated/
38
+ │ ├── form-meta.ts # Auto-generated form field metadata
39
+ │ └── grid-meta.ts # Auto-generated grid column metadata
40
+ ├── tests/
41
+ │ └── e2e/ # 9 Playwright E2E specs
42
+ ├── nuxt.config.ts
43
+ └── vitest.config.ts
44
+ ```
45
+
46
+ ## Key Features
47
+
48
+ - **Form system** -- Metadata-driven forms built from auto-generated `form-meta.ts`. The `useEntityForm` composable handles field generation, Zod validation schemas, API submission, and navigation. `FormBase` renders fields by input type; `FormPage` provides the page shell.
49
+ - **Grid system** -- Metadata-driven data grids built from auto-generated `grid-meta.ts`. The `useGridFetch`/`useGridData` composables handle paginated API fetching. `GridBase` renders columns using type-appropriate renderers. Supports sorting, search, filters, column visibility, row click navigation, row actions, and bulk actions with confirmation dialogs.
50
+ - **Permission guards** -- Pages declare required permissions via `definePageMeta({ permission: '...' })`. Grid add buttons, row actions, and bulk actions respect permission checks.
51
+ - **Entity CRUD pages** -- 10 admin entities with full index/create/edit page sets under `/motor-admin/`.
52
+ - **i18n** -- 13 translation modules per locale (de, en) covering all entities plus global strings.
53
+
54
+ ## Dependencies
55
+
56
+ This layer heavily consumes **motor-ui-core** for:
57
+ - Composables: `useEntityForm`, `useGridFetch`, `useGridData`, `columnsFromMeta`, `createdAtColumn`, `useBreadcrumbs`, `usePermissions`, `useClientFilter`, `useIsActiveFilter`
58
+ - Types: `FormInputProps`, `FormInputValue`, `RendererProps`, `BulkActionDef`, `BreadcrumbItem`, API schemas from `api.d.ts`
59
+ - Form configs: per-entity configs in `motor-ui-core/app/types/config/`
60
+
61
+ ## Generated Metadata
62
+
63
+ `form-meta.ts` and `grid-meta.ts` are auto-generated from the backend OpenAPI spec. Regenerate with:
64
+
65
+ ```bash
66
+ pnpm sync:api
67
+ ```
68
+
69
+ Do not edit these files manually.
70
+
71
+ ## Tests
72
+
73
+ 9 Playwright E2E specs under `tests/e2e/` covering grid loading, row navigation, and pagination for users, roles, clients, domains, languages, config variables, email templates, and category trees.
74
+
75
+ ```bash
76
+ pnpm test:e2e
77
+ ```
@@ -0,0 +1,154 @@
1
+ <script setup lang="ts">
2
+ import type { TreeItem } from '@nuxt/ui'
3
+
4
+ const { t } = useI18n()
5
+
6
+ interface CategoryTreeItem extends TreeItem {
7
+ id: number
8
+ children?: CategoryTreeItem[]
9
+ }
10
+
11
+ interface Category {
12
+ id: number
13
+ name: string
14
+ parent_id: number | null
15
+ _lft: number
16
+ level: number
17
+ }
18
+
19
+ const props = defineProps<{
20
+ scope: string
21
+ modelValue?: number[]
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ 'update:modelValue': [value: number[]]
26
+ }>()
27
+
28
+ const client = useSanctumClient()
29
+
30
+ const treeItems = ref<CategoryTreeItem[]>([])
31
+ const itemMap = new Map<number, CategoryTreeItem>()
32
+ const selectedIds = ref(new Set<number>())
33
+
34
+ function buildTree(categories: Category[]): CategoryTreeItem[] {
35
+ const sorted = [...categories].sort((a, b) => a._lft - b._lft)
36
+ const map = new Map<number, CategoryTreeItem>()
37
+ const roots: CategoryTreeItem[] = []
38
+
39
+ for (const cat of sorted) {
40
+ const item: CategoryTreeItem = {
41
+ id: cat.id,
42
+ label: cat.name,
43
+ children: [],
44
+ defaultExpanded: true
45
+ }
46
+ map.set(cat.id, item)
47
+ itemMap.set(cat.id, item)
48
+
49
+ if (cat.level === 0) continue // skip tree root
50
+
51
+ if (cat.parent_id && map.has(cat.parent_id) && map.get(cat.parent_id) !== undefined) {
52
+ const parent = map.get(cat.parent_id)!
53
+ if (categories.find(c => c.id === cat.parent_id)!.level > 0) {
54
+ parent.children!.push(item)
55
+ continue
56
+ }
57
+ }
58
+ roots.push(item)
59
+ }
60
+
61
+ return roots
62
+ }
63
+
64
+ function isSelected(item: CategoryTreeItem): boolean {
65
+ return selectedIds.value.has(item.id)
66
+ }
67
+
68
+ function getAllDescendantIds(item: CategoryTreeItem): number[] {
69
+ const ids: number[] = []
70
+ for (const child of item.children ?? []) {
71
+ ids.push(child.id)
72
+ ids.push(...getAllDescendantIds(child))
73
+ }
74
+ return ids
75
+ }
76
+
77
+ function isIndeterminate(item: CategoryTreeItem): boolean {
78
+ if (!item.children?.length) return false
79
+ const descendantIds = getAllDescendantIds(item)
80
+ const selectedCount = descendantIds.filter(id => selectedIds.value.has(id)).length
81
+ return selectedCount > 0 && selectedCount < descendantIds.length
82
+ }
83
+
84
+ function toggleItem(item: CategoryTreeItem) {
85
+ const ids = new Set(selectedIds.value)
86
+ if (ids.has(item.id)) {
87
+ ids.delete(item.id)
88
+ } else {
89
+ ids.add(item.id)
90
+ }
91
+ selectedIds.value = ids
92
+ emit('update:modelValue', [...ids])
93
+ }
94
+
95
+ // Sync incoming modelValue (IDs) → selectedIds
96
+ function syncFromIds() {
97
+ if (!props.modelValue || props.modelValue.length === 0) {
98
+ selectedIds.value = new Set()
99
+ return
100
+ }
101
+ selectedIds.value = new Set(props.modelValue.filter(id => itemMap.has(id)))
102
+ }
103
+
104
+ watch(() => props.modelValue, syncFromIds, { deep: true })
105
+
106
+ const { data: categoriesRes, status: fetchStatus } = useAsyncData<{ data: Category[] }>(
107
+ `category-tree-input-${props.scope}`,
108
+ () => client<{ data: Category[] }>(`/api/v2/categories?scope=${props.scope}&per_page=0`)
109
+ )
110
+
111
+ const loading = computed(() => fetchStatus.value === 'pending')
112
+
113
+ watch(categoriesRes, (res) => {
114
+ if (res?.data) {
115
+ treeItems.value = buildTree(res.data)
116
+ syncFromIds()
117
+ }
118
+ }, { immediate: true })
119
+ </script>
120
+
121
+ <template>
122
+ <div
123
+ v-if="loading"
124
+ class="flex items-center gap-2 text-sm text-muted py-2"
125
+ >
126
+ <UIcon
127
+ name="i-lucide-loader-2"
128
+ class="size-4 animate-spin"
129
+ />
130
+ <span>{{ t('motor-core.global.loading') }}</span>
131
+ </div>
132
+ <UTree
133
+ v-else
134
+ :items="treeItems"
135
+ :get-key="(item: CategoryTreeItem) => String(item.id)"
136
+ >
137
+ <template #item-leading="{ item }">
138
+ <UCheckbox
139
+ :model-value="isIndeterminate(item as CategoryTreeItem) ? 'indeterminate' : isSelected(item as CategoryTreeItem)"
140
+ tabindex="-1"
141
+ @change="toggleItem(item as CategoryTreeItem)"
142
+ @click.stop
143
+ />
144
+ </template>
145
+ <template #item-label="{ item }">
146
+ <span
147
+ class="cursor-pointer select-none"
148
+ @click.stop="toggleItem(item as CategoryTreeItem)"
149
+ >
150
+ {{ (item as CategoryTreeItem).label }}
151
+ </span>
152
+ </template>
153
+ </UTree>
154
+ </template>
@@ -0,0 +1,355 @@
1
+ <!-- app/components/form/inputs/CategoryTreePicker.vue -->
2
+ <script setup lang="ts">
3
+ import type { TreeNode } from '@motor-cms/ui-core/app/types/tree'
4
+
5
+ interface CategoryItem {
6
+ id: number
7
+ name: string
8
+ parent_id: number | null
9
+ level: number
10
+ }
11
+
12
+ const TEMP_NEW_ID = -1
13
+
14
+ const props = defineProps<{
15
+ treeId: string
16
+ modelValue: number | null
17
+ /** API endpoint to fetch tree items (defaults to category-trees endpoint) */
18
+ endpoint?: string
19
+ /** ID of the category being edited — shown highlighted & draggable */
20
+ currentId?: number
21
+ /** Name for a new item (create mode) — shown as a draggable node */
22
+ newItemName?: string
23
+ }>()
24
+
25
+ const emit = defineEmits<{
26
+ 'update:modelValue': [value: number | null]
27
+ 'update:previousSiblingId': [value: number | null]
28
+ 'update:nextSiblingId': [value: number | null]
29
+ }>()
30
+
31
+ const { t } = useI18n()
32
+ const sanctumClient = useSanctumClient()
33
+
34
+ const tree = ref<TreeNode[]>([])
35
+ const isCreateMode = computed(() => props.newItemName !== undefined && !props.currentId)
36
+ const isEditMode = computed(() => !!props.currentId || isCreateMode.value)
37
+ const activeItemId = computed(() => props.currentId ?? (isCreateMode.value ? TEMP_NEW_ID : undefined))
38
+
39
+ // The parent_id of root-level categories (their parent is outside the returned list)
40
+ const rootParentId = ref<number | null>(null)
41
+ const isDragging = ref(false)
42
+ const expandedIds = ref(new Set<number>())
43
+
44
+ // IDs that can't be a drop/select target (current item + its descendants)
45
+ const nonSelectableIds = computed(() => {
46
+ const ids = new Set<number>()
47
+ if (!props.currentId) return ids
48
+ ids.add(props.currentId)
49
+ let changed = true
50
+ while (changed) {
51
+ changed = false
52
+ for (const cat of categories.value) {
53
+ if (cat.parent_id !== null && ids.has(cat.parent_id) && !ids.has(cat.id)) {
54
+ ids.add(cat.id)
55
+ changed = true
56
+ }
57
+ }
58
+ }
59
+ return ids
60
+ })
61
+
62
+ const { data: categoriesRes, status: fetchStatus } = useAsyncData<{ data: CategoryItem[] }>(
63
+ `category-tree-picker-${props.treeId}`,
64
+ () => {
65
+ const url = props.endpoint
66
+ ? `${props.endpoint}?per_page=0`
67
+ : `/api/v2/category-trees/${props.treeId}/categories?per_page=0`
68
+ return sanctumClient<{ data: CategoryItem[] }>(url)
69
+ }
70
+ )
71
+
72
+ const categories = computed(() => categoriesRes.value?.data ?? [])
73
+ const loading = computed(() => fetchStatus.value === 'pending')
74
+
75
+ function buildTree(cats: CategoryItem[]): TreeNode[] {
76
+ const map = new Map<number, TreeNode>()
77
+ for (const cat of cats) {
78
+ map.set(cat.id, { id: cat.id, name: cat.name, children: [] })
79
+ }
80
+ const roots: TreeNode[] = []
81
+ for (const cat of cats) {
82
+ const node = map.get(cat.id)!
83
+ if (cat.parent_id !== null && map.has(cat.parent_id)) {
84
+ map.get(cat.parent_id)!.children.push(node)
85
+ } else {
86
+ roots.push(node)
87
+ // Capture the parent_id of root-level categories
88
+ if (rootParentId.value === null && cat.parent_id !== null) {
89
+ rootParentId.value = cat.parent_id
90
+ }
91
+ }
92
+ }
93
+ return roots
94
+ }
95
+
96
+ // Find ancestor IDs from root to target (not including target)
97
+ function findPathTo(nodes: TreeNode[], targetId: number): number[] | null {
98
+ for (const node of nodes) {
99
+ if (node.id === targetId) return []
100
+ if (node.children.length > 0) {
101
+ const path = findPathTo(node.children, targetId)
102
+ if (path !== null) return [node.id, ...path]
103
+ }
104
+ }
105
+ return null
106
+ }
107
+
108
+ watch(categories, (cats) => {
109
+ tree.value = buildTree(cats)
110
+
111
+ // Empty tree: default rootParentId to the tree's own ID (root node)
112
+ if (rootParentId.value === null) {
113
+ rootParentId.value = Number(props.treeId)
114
+ }
115
+
116
+ // Create mode: insert virtual "new" node at the start of the root list
117
+ if (isCreateMode.value) {
118
+ tree.value.unshift({ id: TEMP_NEW_ID, name: props.newItemName || '', children: [] })
119
+ // Emit initial position (first child at root level)
120
+ emitPosition()
121
+ }
122
+
123
+ // Auto-expand path to current item (+ the item itself to show its children)
124
+ if (props.currentId) {
125
+ const path = findPathTo(tree.value, props.currentId)
126
+ if (path) {
127
+ expandedIds.value = new Set([...path, props.currentId])
128
+ }
129
+ }
130
+ }, { immediate: true })
131
+
132
+ // Keep the active node's name in sync with the prop
133
+ watch(() => props.newItemName, (name) => {
134
+ const id = activeItemId.value
135
+ if (id === undefined) return
136
+ const node = findNode(tree.value, id)
137
+ if (node) node.name = name || ''
138
+ })
139
+
140
+ function toggleNode(id: number) {
141
+ const next = new Set(expandedIds.value)
142
+ if (next.has(id)) {
143
+ next.delete(id)
144
+ } else {
145
+ next.add(id)
146
+ }
147
+ expandedIds.value = next
148
+ }
149
+
150
+ // =============================================
151
+ // Tree mutation (edit mode drag & drop)
152
+ // =============================================
153
+
154
+ function findAndRemove(nodes: TreeNode[], id: number): TreeNode | null {
155
+ for (let i = 0; i < nodes.length; i++) {
156
+ const node = nodes[i]!
157
+ if (node.id === id) return nodes.splice(i, 1)[0] ?? null
158
+ const found = findAndRemove(node.children, id)
159
+ if (found) return found
160
+ }
161
+ return null
162
+ }
163
+
164
+ function findNode(nodes: TreeNode[], id: number): TreeNode | null {
165
+ for (const n of nodes) {
166
+ if (n.id === id) return n
167
+ const found = findNode(n.children, id)
168
+ if (found) return found
169
+ }
170
+ return null
171
+ }
172
+
173
+ /** Find the list containing the active item and emit its parent + siblings */
174
+ function emitPosition() {
175
+ const id = activeItemId.value
176
+ if (id === undefined) return
177
+
178
+ function findInList(nodes: TreeNode[], parentId: number | null): boolean {
179
+ for (let i = 0; i < nodes.length; i++) {
180
+ if (nodes[i]!.id === id) {
181
+ const prev = i > 0 ? (nodes[i - 1]?.id ?? null) : null
182
+ const next = i < nodes.length - 1 ? (nodes[i + 1]?.id ?? null) : null
183
+ emit('update:modelValue', parentId)
184
+ emit('update:previousSiblingId', prev)
185
+ emit('update:nextSiblingId', next)
186
+ return true
187
+ }
188
+ if (findInList(nodes[i]!.children, nodes[i]!.id)) return true
189
+ }
190
+ return false
191
+ }
192
+ findInList(tree.value, rootParentId.value)
193
+ }
194
+
195
+ function moveItem(itemId: number, newParentId: number | null, newIndex: number) {
196
+ const item = findAndRemove(tree.value, itemId)
197
+ if (!item) return
198
+
199
+ let targetList: TreeNode[]
200
+ if (newParentId === null || newParentId === rootParentId.value) {
201
+ targetList = tree.value
202
+ } else {
203
+ const parent = findNode(tree.value, newParentId)
204
+ if (!parent) return
205
+ targetList = parent.children
206
+ }
207
+
208
+ targetList.splice(newIndex, 0, item)
209
+
210
+ // Auto-expand the target parent so the user sees the result
211
+ if (newParentId !== null && !expandedIds.value.has(newParentId)) {
212
+ const next = new Set(expandedIds.value)
213
+ next.add(newParentId)
214
+ expandedIds.value = next
215
+ }
216
+
217
+ emitPosition()
218
+ }
219
+
220
+ // =============================================
221
+ // Auto-expand collapsed parents on drag hover
222
+ // =============================================
223
+
224
+ const autoExpandedIds = new Set<number>()
225
+ let hoverTimer: ReturnType<typeof setTimeout> | null = null
226
+ let hoverTargetId: number | null = null
227
+
228
+ function isDescendantOf(nodeId: number, ancestorId: number): boolean {
229
+ const catMap = new Map(categories.value.map(c => [c.id, c]))
230
+ let current = catMap.get(nodeId)
231
+ while (current) {
232
+ if (current.parent_id === ancestorId) return true
233
+ if (current.parent_id === null) return false
234
+ current = current.parent_id !== null ? catMap.get(current.parent_id) : undefined
235
+ }
236
+ return false
237
+ }
238
+
239
+ function collapseStaleAutoExpanded(currentItemId: number) {
240
+ if (autoExpandedIds.size === 0) return
241
+ const toRemove: number[] = []
242
+ for (const id of autoExpandedIds) {
243
+ if (id === currentItemId || isDescendantOf(currentItemId, id)) continue
244
+ toRemove.push(id)
245
+ }
246
+ if (toRemove.length === 0) return
247
+ const next = new Set(expandedIds.value)
248
+ for (const id of toRemove) {
249
+ next.delete(id)
250
+ autoExpandedIds.delete(id)
251
+ }
252
+ expandedIds.value = next
253
+ }
254
+
255
+ function cancelHoverExpand() {
256
+ if (hoverTimer) {
257
+ clearTimeout(hoverTimer)
258
+ hoverTimer = null
259
+ hoverTargetId = null
260
+ }
261
+ }
262
+
263
+ function startHoverExpand(id: number) {
264
+ if (id === hoverTargetId) return
265
+ cancelHoverExpand()
266
+ hoverTargetId = id
267
+ hoverTimer = setTimeout(() => {
268
+ const next = new Set(expandedIds.value)
269
+ next.add(id)
270
+ expandedIds.value = next
271
+ autoExpandedIds.add(id)
272
+ hoverTargetId = null
273
+ hoverTimer = null
274
+ }, 500)
275
+ }
276
+
277
+ /** Called from @dragover on each tree item during drag */
278
+ function handleDragOver(itemId: number) {
279
+ collapseStaleAutoExpanded(itemId)
280
+
281
+ if (itemId === activeItemId.value) {
282
+ cancelHoverExpand()
283
+ return
284
+ }
285
+ if (nonSelectableIds.value.has(itemId)) {
286
+ cancelHoverExpand()
287
+ return
288
+ }
289
+ if (expandedIds.value.has(itemId)) {
290
+ cancelHoverExpand()
291
+ return
292
+ }
293
+
294
+ const node = findNode(tree.value, itemId)
295
+ if (!node || node.children.length === 0) {
296
+ cancelHoverExpand()
297
+ return
298
+ }
299
+
300
+ startHoverExpand(itemId)
301
+ }
302
+
303
+ /** Called after drop — keeps drop target expanded, collapses the rest */
304
+ function cleanupAutoExpand(dropParentId: number | null) {
305
+ cancelHoverExpand()
306
+ if (dropParentId !== null) {
307
+ autoExpandedIds.delete(dropParentId)
308
+ }
309
+ if (autoExpandedIds.size === 0) return
310
+ const next = new Set(expandedIds.value)
311
+ for (const id of autoExpandedIds) {
312
+ next.delete(id)
313
+ }
314
+ expandedIds.value = next
315
+ autoExpandedIds.clear()
316
+ }
317
+
318
+ // Provide shared state for the recursive NestedDraggable components
319
+ provide('categoryTree', {
320
+ currentId: computed(() => activeItemId.value),
321
+ nonSelectableIds,
322
+ moveItem,
323
+ isEditMode,
324
+ isDragging,
325
+ expandedIds: computed(() => expandedIds.value),
326
+ toggleNode,
327
+ handleDragOver,
328
+ cleanupAutoExpand
329
+ })
330
+ </script>
331
+
332
+ <template>
333
+ <ClientOnly>
334
+ <div
335
+ v-if="loading"
336
+ class="flex items-center gap-2 text-sm text-[var(--ui-text-muted)]"
337
+ >
338
+ <UIcon
339
+ name="i-lucide-loader-2"
340
+ class="animate-spin"
341
+ />
342
+ {{ t('motor-core.global.loading') }}
343
+ </div>
344
+ <div
345
+ v-else
346
+ class="text-sm"
347
+ >
348
+ <FormInputsNestedDraggable
349
+ :items="tree"
350
+ :parent-id="rootParentId"
351
+ :depth="0"
352
+ />
353
+ </div>
354
+ </ClientOnly>
355
+ </template>