@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.
- package/README.md +77 -0
- package/app/components/form/inputs/CategoryTreeInput.vue +154 -0
- package/app/components/form/inputs/CategoryTreePicker.vue +355 -0
- package/app/components/form/inputs/NestedDraggable.vue +217 -0
- package/app/components/form/inputs/QuicklinksInput.vue +186 -0
- package/app/lang/de/motor-admin/CLAUDE.md +21 -0
- package/app/lang/de/motor-admin/ai_system_prompts.json +12 -0
- package/app/lang/de/motor-admin/categories.json +12 -0
- package/app/lang/de/motor-admin/category_trees.json +14 -0
- package/app/lang/de/motor-admin/clients.json +26 -0
- package/app/lang/de/motor-admin/config_variables.json +14 -0
- package/app/lang/de/motor-admin/domains.json +19 -0
- package/app/lang/de/motor-admin/email_templates.json +38 -0
- package/app/lang/de/motor-admin/global.json +5 -0
- package/app/lang/de/motor-admin/languages.json +16 -0
- package/app/lang/de/motor-admin/permissions.json +14 -0
- package/app/lang/de/motor-admin/roles.json +15 -0
- package/app/lang/de/motor-admin/users.json +22 -0
- package/app/lang/en/motor-admin/CLAUDE.md +7 -0
- package/app/lang/en/motor-admin/ai_system_prompts.json +12 -0
- package/app/lang/en/motor-admin/categories.json +12 -0
- package/app/lang/en/motor-admin/category_trees.json +14 -0
- package/app/lang/en/motor-admin/clients.json +26 -0
- package/app/lang/en/motor-admin/config_variables.json +14 -0
- package/app/lang/en/motor-admin/domains.json +18 -0
- package/app/lang/en/motor-admin/email_templates.json +33 -0
- package/app/lang/en/motor-admin/global.json +5 -0
- package/app/lang/en/motor-admin/languages.json +16 -0
- package/app/lang/en/motor-admin/permissions.json +14 -0
- package/app/lang/en/motor-admin/roles.json +15 -0
- package/app/lang/en/motor-admin/users.json +22 -0
- package/app/pages/dashboard.vue +5 -0
- package/app/pages/index.vue +39 -0
- package/app/pages/login.vue +85 -0
- package/app/pages/motor-admin/ai-system-prompts/CLAUDE.md +7 -0
- package/app/pages/motor-admin/ai-system-prompts/[id]/edit.vue +48 -0
- package/app/pages/motor-admin/ai-system-prompts/create.vue +40 -0
- package/app/pages/motor-admin/ai-system-prompts/index.vue +68 -0
- package/app/pages/motor-admin/category-trees/CLAUDE.md +7 -0
- package/app/pages/motor-admin/category-trees/[id]/CLAUDE.md +7 -0
- package/app/pages/motor-admin/category-trees/[id]/categories/[categoryId]/edit.vue +73 -0
- package/app/pages/motor-admin/category-trees/[id]/categories/create.vue +64 -0
- package/app/pages/motor-admin/category-trees/[id]/edit.vue +45 -0
- package/app/pages/motor-admin/category-trees/[id]/index.vue +81 -0
- package/app/pages/motor-admin/category-trees/create.vue +37 -0
- package/app/pages/motor-admin/category-trees/index.vue +54 -0
- package/app/pages/motor-admin/clients/CLAUDE.md +11 -0
- package/app/pages/motor-admin/clients/[id]/CLAUDE.md +11 -0
- package/app/pages/motor-admin/clients/[id]/edit.vue +45 -0
- package/app/pages/motor-admin/clients/create.vue +37 -0
- package/app/pages/motor-admin/clients/index.vue +46 -0
- package/app/pages/motor-admin/config-variables/CLAUDE.md +11 -0
- package/app/pages/motor-admin/config-variables/[id]/edit.vue +44 -0
- package/app/pages/motor-admin/config-variables/create.vue +36 -0
- package/app/pages/motor-admin/config-variables/index.vue +66 -0
- package/app/pages/motor-admin/domains/CLAUDE.md +11 -0
- package/app/pages/motor-admin/domains/[id]/edit.vue +54 -0
- package/app/pages/motor-admin/domains/create.vue +46 -0
- package/app/pages/motor-admin/domains/index.vue +98 -0
- package/app/pages/motor-admin/email-templates/CLAUDE.md +12 -0
- package/app/pages/motor-admin/email-templates/[id]/CLAUDE.md +7 -0
- package/app/pages/motor-admin/email-templates/[id]/edit.vue +56 -0
- package/app/pages/motor-admin/email-templates/create.vue +48 -0
- package/app/pages/motor-admin/email-templates/index.vue +67 -0
- package/app/pages/motor-admin/index.vue +12 -0
- package/app/pages/motor-admin/languages/CLAUDE.md +7 -0
- package/app/pages/motor-admin/languages/[id]/edit.vue +44 -0
- package/app/pages/motor-admin/languages/create.vue +36 -0
- package/app/pages/motor-admin/languages/index.vue +44 -0
- package/app/pages/motor-admin/permission-groups/CLAUDE.md +14 -0
- package/app/pages/motor-admin/permission-groups/[id]/CLAUDE.md +11 -0
- package/app/pages/motor-admin/permission-groups/[id]/edit.vue +49 -0
- package/app/pages/motor-admin/permission-groups/create.vue +41 -0
- package/app/pages/motor-admin/permission-groups/index.vue +43 -0
- package/app/pages/motor-admin/roles/CLAUDE.md +7 -0
- package/app/pages/motor-admin/roles/[id]/edit.vue +47 -0
- package/app/pages/motor-admin/roles/create.vue +40 -0
- package/app/pages/motor-admin/roles/index.vue +45 -0
- package/app/pages/motor-admin/theme-preview/CLAUDE.md +7 -0
- package/app/pages/motor-admin/theme-preview/index.vue +4801 -0
- package/app/pages/motor-admin/theme-preview/themes/CLAUDE.md +11 -0
- package/app/pages/motor-admin/theme-preview/themes/asymmetric-brutalist.md +381 -0
- package/app/pages/motor-admin/theme-preview/themes/bold-modern.md +231 -0
- package/app/pages/motor-admin/theme-preview/themes/geometric-minimal.md +778 -0
- package/app/pages/motor-admin/theme-preview/themes/gradient-flow.md +1057 -0
- package/app/pages/motor-admin/theme-preview/themes/liquid-glass.md +823 -0
- package/app/pages/motor-admin/theme-preview/themes/neon-amber.md +1223 -0
- package/app/pages/motor-admin/theme-preview/themes/neon-terminal.md +779 -0
- package/app/pages/motor-admin/theme-preview/themes/neon-violet.md +1134 -0
- package/app/pages/motor-admin/theme-preview/themes/professional-clean.md +232 -0
- package/app/pages/motor-admin/theme-preview/themes/refined-brutalist.md +462 -0
- package/app/pages/motor-admin/theme-preview/themes/wild-card.md +263 -0
- package/app/pages/motor-admin/users/CLAUDE.md +17 -0
- package/app/pages/motor-admin/users/[id]/CLAUDE.md +11 -0
- package/app/pages/motor-admin/users/[id]/edit.vue +83 -0
- package/app/pages/motor-admin/users/create.vue +40 -0
- package/app/pages/motor-admin/users/index.vue +66 -0
- package/app/pages/profile.vue +363 -0
- package/app/pages/search.vue +91 -0
- package/app/types/generated/form-meta.ts +258 -0
- package/app/types/generated/grid-meta.ts +172 -0
- package/nuxt.config.ts +1 -0
- 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>
|