MenuManager

This commit is contained in:
Sedat ÖZTÜRK 2025-06-26 16:58:53 +03:00
parent e9903d5dc0
commit b2dc2251d8
17 changed files with 814 additions and 63 deletions

View file

@ -2468,13 +2468,13 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
{
CultureName = LanguageCodes.En,
ListFormCode = ListFormCodes.Lists.Menu,
Name = AppCodes.Menus,
Title = AppCodes.Menus,
Name = AppCodes.Menus.Menu,
Title = AppCodes.Menus.Menu,
DataSourceCode = SeedConsts.DataSources.DefaultCode,
IsTenant = false,
IsBranch = false,
IsOrganizationUnit = false,
Description = AppCodes.Menus,
Description = AppCodes.Menus.Menu,
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = SelectCommandByTableName("Menu"),
KeyFieldName = "Id",
@ -2509,11 +2509,11 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new PermissionCrudDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
D = AppCodes.Menus + ".Delete",
E = AppCodes.Menus + ".Export"
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
D = AppCodes.Menus.Menu + ".Delete",
E = AppCodes.Menus.Menu + ".Export"
}),
DeleteCommand = $"UPDATE \"{DbTablePrefix}Menu\" SET \"DeleterId\"=@DeleterId, \"DeletionTime\"=CURRENT_TIMESTAMP, \"IsDeleted\"='true' WHERE \"Id\"=@Id",
DeleteFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
@ -2615,9 +2615,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
IsDeleted = false,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2647,9 +2647,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2684,9 +2684,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2713,9 +2713,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2740,9 +2740,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
AllowSearch = true,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2767,9 +2767,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
AllowSearch = true,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2801,9 +2801,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2828,9 +2828,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
AllowSearch = true,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2862,9 +2862,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
}),
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2889,9 +2889,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
AllowSearch = true,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2915,9 +2915,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
IsDeleted = false,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),
@ -2942,9 +2942,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
AllowSearch = true,
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
{
C = AppCodes.Menus + ".Create",
R = AppCodes.Menus,
U = AppCodes.Menus + ".Update",
C = AppCodes.Menus.Menu + ".Create",
R = AppCodes.Menus.Menu,
U = AppCodes.Menus.Menu + ".Update",
E = true,
Deny = false
}),

View file

@ -606,6 +606,12 @@
"en": "Update",
"tr": "Değiştir"
},
{
"resourceName": "Platform",
"key": "Manager",
"en": "Manager",
"tr": "Yönetici"
},
{
"resourceName": "Platform",
"key": "Cancel",
@ -960,6 +966,18 @@
"en": "Menu Management",
"tr": "Menü Yönetimi"
},
{
"resourceName": "Platform",
"key": "App.Menus.Menu",
"en": "Menu List",
"tr": "Menü Listesi"
},
{
"resourceName": "Platform",
"key": "App.Menus.Manager",
"en": "Menu Manager",
"tr": "Menü Yöneticisi"
},
{
"resourceName": "Platform",
"key": "App.Setting",
@ -6285,9 +6303,29 @@
"Code": "App.Menus",
"DisplayName": "App.Menus",
"Order": 5,
"Url": null,
"Icon": "FaSchlix",
"RequiredPermissionName": null,
"IsDisabled": false
},
{
"ParentCode": "App.Menus",
"Code": "App.Menus.Menu",
"DisplayName": "App.Menus.Menu",
"Order": 1,
"Url": "/list/list-menu",
"Icon": "FcMenu",
"RequiredPermissionName": "App.Menus",
"RequiredPermissionName": "App.Menus.Menu",
"IsDisabled": false
},
{
"ParentCode": "App.Menus",
"Code": "App.Menus.Manager",
"DisplayName": "App.Menus.Manager",
"Order": 2,
"Url": "/menumanager",
"Icon": "FaRegListAlt",
"RequiredPermissionName": "App.Menus.Manager",
"IsDisabled": false
},
{
@ -6392,7 +6430,7 @@
},
{
"ParentCode": "App.Saas",
"Code": "AApp.BlogManagement",
"Code": "App.BlogManagement",
"DisplayName": "App.BlogManagement",
"Order": 10,
"Url": "/admin/blogmanagement",
@ -6857,9 +6895,9 @@
},
{
"GroupName": "App.Menus",
"Name": "App.Menus",
"Name": "App.Menus.Menu",
"ParentName": null,
"DisplayName": "App.Menus",
"DisplayName": "App.Menus.Menu",
"IsEnabled": true,
"MultiTenancySide": 2
},
@ -7609,36 +7647,44 @@
},
{
"GroupName": "App.Menus",
"Name": "App.Menus.Create",
"ParentName": "App.Menus",
"Name": "App.Menus.Menu.Create",
"ParentName": "App.Menus.Menu",
"DisplayName": "Create",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Menus",
"Name": "App.Menus.Delete",
"ParentName": "App.Menus",
"Name": "App.Menus.Menu.Delete",
"ParentName": "App.Menus.Menu",
"DisplayName": "Delete",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Menus",
"Name": "App.Menus.Export",
"ParentName": "App.Menus",
"Name": "App.Menus.Menu.Export",
"ParentName": "App.Menus.Menu",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Menus",
"Name": "App.Menus.Update",
"ParentName": "App.Menus",
"Name": "App.Menus.Menu.Update",
"ParentName": "App.Menus.Menu",
"DisplayName": "Update",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Menus",
"Name": "App.Menus.Manager",
"ParentName": null,
"DisplayName": "App.Menus.Manager",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Notifications.Notification",
"Name": "App.Notifications.Notification.Create",

View file

@ -323,7 +323,13 @@ public static class SeedConsts
public const string Language = Default + ".Language";
public const string LanguageText = Default + ".LanguageText";
}
public const string Menus = Prefix.App + ".Menus";
public static class Menus
{
public const string Default = Prefix.App + ".Menus";
public const string Menu = Default + ".Menu";
public const string Manager = Default + ".Manager";
}
public static class Listforms
{
public const string Default = Prefix.App + ".Listforms";

View file

@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.e088p05bgmo"
"revision": "0.scg6n9r90k8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

56
ui/package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "kurs-platform-ui",
"version": "1.0.4",
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.2",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.8",
@ -1653,6 +1656,59 @@
"inferno-hydrate": "^7.4.6"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",

View file

@ -15,6 +15,9 @@
"format": "npm run prettier:fix && npm run lint:fix"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.2",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.8",

31
ui/src/@types/menu.ts Normal file
View file

@ -0,0 +1,31 @@
import { MenuDto } from "@/proxy/menus";
export interface MenuItem extends MenuDto {
children?: MenuItem[];
}
export interface MenuApiResponse {
totalCount: number;
items: MenuItem[];
}
export interface DragEndEvent {
active: {
id: string;
data: {
current: {
type: string;
item: MenuItem;
};
};
};
over: {
id: string;
data: {
current: {
type: string;
item?: MenuItem;
};
};
} | null;
}

View file

@ -75,4 +75,10 @@ export const protectedRoutes: Routes = [
component: lazy(() => import('@/views/docs/ChangeLog')),
authority: [],
},
{
key: ROUTES_ENUM.menumanager,
path: ROUTES_ENUM.menumanager,
component: lazy(() => import('@/views/menu/MenuManager')),
authority: [],
},
]

View file

@ -53,4 +53,5 @@ export const ROUTES_ENUM = {
docs: {
changelog: '/docs/changelog'
},
menumanager: '/menumanager',
}

View file

@ -1,2 +1 @@
export * from './menu.service'
export * from './models'

View file

@ -1,6 +1,6 @@
import { PagedAndSortedResultRequestDto, PagedResultDto } from '@/proxy/abp'
import { MenuDto } from '@/proxy/menus/models'
import apiService, { Config } from '@/services/api.service'
import type { MenuDto } from './models'
import { PagedAndSortedResultRequestDto, PagedResultDto } from '../abp'
export class MenuService {
apiName = 'Default'

View file

@ -1,5 +1,4 @@
import { refreshToken } from '@/proxy/account/auth.service'
import { MenuService } from '@/proxy/menus'
import { createStore, createTypedHooks, persist } from 'easy-peasy'
import {
Config as ReduxStateSyncConfig,
@ -14,6 +13,7 @@ import { AuthModel, authModel } from './auth.model'
import { BaseModel, baseModel } from './base.model'
import { LocaleModel, localeModel } from './locale.model'
import { ThemeModel, themeModel } from './theme.model'
import { MenuService } from '@/services/menu.service'
export interface StoreModel {
abpConfig: AbpConfigModel

View file

@ -0,0 +1,116 @@
import { MenuApiResponse, MenuItem } from '@/@types/menu'
import { getMenus } from '@/services/menu.service'
import { useState, useEffect } from 'react'
export const useMenuData = () => {
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const buildHierarchy = (items: MenuItem[]): MenuItem[] => {
const itemMap = new Map<string, MenuItem>()
const rootItems: MenuItem[] = []
// Create a map for quick lookup and initialize children arrays
items.forEach((item) => {
itemMap.set(item.code!!, { ...item, children: [] })
})
// Build the hierarchy
items.forEach((item) => {
const menuItem = itemMap.get(item.code!!)!
if (item.parentCode && itemMap.has(item.parentCode)) {
const parent = itemMap.get(item.parentCode)!
if (!parent.children) {
parent.children = []
}
parent.children.push(menuItem)
} else {
rootItems.push(menuItem)
}
})
// Sort items by order recursively
const sortItems = (items: MenuItem[]): MenuItem[] => {
return items
.sort((a, b) => a.order - b.order)
.map((item) => ({
...item,
children: item.children && item.children.length > 0 ? sortItems(item.children) : [],
}))
}
return sortItems(rootItems)
}
const fetchMenuData = async () => {
try {
setLoading(true)
setError(null)
// Simulate API call with mock data
await new Promise((resolve) => setTimeout(resolve, 1000))
const response = await getMenus()
if (response.data) {
const hierarchicalMenu = buildHierarchy(response.data.items)
setMenuItems(hierarchicalMenu)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load menu data')
} finally {
setLoading(false)
}
}
const saveMenuData = async (updatedMenuItems: MenuItem[]) => {
try {
// Flatten the hierarchy for API
const flatten = (items: MenuItem[], parentCode: string | null = null): MenuItem[] => {
const result: MenuItem[] = []
items.forEach((item, index) => {
const flatItem = {
...item,
parentCode,
order: index + 1,
children: undefined,
}
result.push(flatItem)
if (item.children && item.children.length > 0) {
result.push(...flatten(item.children, item.code))
}
})
return result
}
const flatMenuItems = flatten(updatedMenuItems)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Saving menu data:', flatMenuItems)
// In real implementation: await api.saveMenus(flatMenuItems);
return { success: true }
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to save menu data')
}
}
useEffect(() => {
fetchMenuData()
}, [])
return {
menuItems,
setMenuItems,
loading,
error,
refetch: fetchMenuData,
saveMenuData,
}
}

View file

@ -14,7 +14,6 @@ import { postListFormWizard } from '@/proxy/admin/list-form/list-form.service'
import { ListFormWizardDto } from '@/proxy/admin/list-form/models'
import { getDataSources } from '@/proxy/data-source'
import { DbTypeEnum, SelectCommandTypeEnum } from '@/proxy/form'
import { getMenus } from '@/proxy/menus'
import { SelectBoxOption } from '@/shared/types'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Field, FieldProps, Form, Formik } from 'formik'
@ -24,6 +23,7 @@ import { useNavigate } from 'react-router-dom'
import CreatableSelect from 'react-select/creatable'
import * as Yup from 'yup'
import { dbSourceTypeOptions, selectCommandTypeOptions } from './edit/options'
import { getMenus } from '@/services/menu.service'
const initialValues: ListFormWizardDto = {
listFormCode: '',

View file

@ -0,0 +1,100 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import * as Icons from 'lucide-react'
import { MenuItem } from '@/@types/menu'
import { useLocalization } from '@/utils/hooks/useLocalization'
interface MenuItemComponentProps {
item: MenuItem
isDesignMode: boolean
depth: number
children?: React.ReactNode
isDragOverlay?: boolean
}
export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
item,
isDesignMode,
depth,
children,
isDragOverlay = false,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id || '',
data: {
type: 'menu-item',
item,
},
disabled: !isDesignMode,
})
const { translate } = useLocalization()
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const getIcon = (iconName: string) => {
const IconComponent = (Icons as any)[iconName]
return IconComponent ? <IconComponent size={16} /> : <Icons.FileText size={16} />
}
const [isExpanded, setIsExpanded] = React.useState(true)
const toggleExpanded = () => {
if (!isDesignMode) {
setIsExpanded(!isExpanded)
}
}
return (
<div className="select-none">
<div
ref={setNodeRef}
style={style}
className={`
flex items-center gap-1 p-1 rounded-lg transition-all duration-200 group min-h-[30px]
${isDesignMode ? 'cursor-move hover:bg-blue-50 border border-transparent hover:border-blue-200' : 'cursor-pointer hover:bg-gray-50'}
${isDragOverlay ? 'shadow-lg bg-white border border-blue-300 z-50' : ''}
${isDragging ? 'opacity-50' : ''}
${item.children && item.children.length > 0 ? 'bg-blue-50' : depth === 0 ? 'bg-white' : 'bg-gray-50'}
`}
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0 text-gray-600">{item.icon && getIcon(item.icon)}</div>
<span
className={`
truncate text-gray-800 leading-6 text-sm
${isDesignMode ? 'font-normal' : item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
`}
>
{translate('::' + item.displayName)}
</span>
{item.url && <Icons.ExternalLink size={12} className="flex-shrink-0 text-gray-400" />}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isDesignMode && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="bg-gray-200 px-2 py-1 rounded">#{item.order}</span>
</div>
)}
{item.children && item.children.length > 0 && (
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
{item.children.length}
</span>
)}
</div>
</div>
{children && (!isDesignMode ? isExpanded : true) && <div className="mt-1">{children}</div>}
</div>
)
}

View file

@ -0,0 +1,160 @@
import React, { useState } from 'react'
import * as Icons from 'lucide-react'
import { SortableMenuTree } from './SortableMenuTree'
import { MenuItem } from '@/@types/menu'
import { useMenuData } from '@/utils/hooks/useMenuData'
export const MenuManager = () => {
const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData()
const [isDesignMode, setIsDesignMode] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
const handleMenuChange = (updatedItems: MenuItem[]) => {
setMenuItems(updatedItems)
}
const handleSave = async () => {
if (!isDesignMode) return
try {
setIsSaving(true)
setSaveMessage(null)
await saveMenuData(menuItems)
setSaveMessage({ type: 'success', text: 'Menu configuration saved successfully!' })
setTimeout(() => setSaveMessage(null), 3000)
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to save menu configuration',
})
setTimeout(() => setSaveMessage(null), 5000)
} finally {
setIsSaving(false)
}
}
const handleToggleDesignMode = () => {
setIsDesignMode(!isDesignMode)
setSaveMessage(null)
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex items-center gap-3 text-gray-600">
<Icons.Loader2 size={24} className="animate-spin" />
<span className="text-lg">Loading menu configuration...</span>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-md max-w-md w-full mx-4">
<div className="flex items-center gap-3 text-red-600 mb-4">
<Icons.AlertCircle size={24} />
<h2 className="text-lg font-semibold">Error Loading Menu</h2>
</div>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={refetch}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full h-full p-6">
{/* Menu Tree */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
{/* Sol kısım: Başlık */}
<div className="flex items-center gap-2">
<Icons.Menu size={20} className="text-gray-600" />
<h2 className="text-lg font-semibold text-gray-900">Menu Manager</h2>
<span className="text-sm text-gray-500">({menuItems.length} root items)</span>
</div>
{/* Sağ kısım: Design Mode + Save butonu */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<span
className={`text-sm font-medium ${isDesignMode ? 'text-blue-600' : 'text-gray-500'}`}
>
Design Mode
</span>
<button
onClick={handleToggleDesignMode}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isDesignMode ? 'bg-blue-600' : 'bg-gray-200'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out
${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
</div>
{
<button
onClick={handleSave}
disabled={!isDesignMode || isSaving}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg transition-colors
${isDesignMode ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'}
${isSaving ? 'opacity-50' : ''}
`}
>
{isSaving ? (
<>
<Icons.Loader2 size={16} className="animate-spin" />
Saving...
</>
) : (
<>
<Icons.Save size={16} />
Save Changes
</>
)}
</button>
}
</div>
</div>
{menuItems.length > 0 ? (
<SortableMenuTree
items={menuItems}
onItemsChange={handleMenuChange}
isDesignMode={isDesignMode}
/>
) : (
<div className="text-center py-12 text-gray-500">
<Icons.Menu size={24} className="mx-auto mb-4 text-gray-300" />
<p className="text-lg">No menu items found</p>
<p className="text-sm">Try refreshing the page or contact your administrator</p>
</div>
)}
</div>
</div>
</div>
)
}
export default MenuManager

View file

@ -0,0 +1,227 @@
import React from 'react'
import {
DndContext,
DragOverlay,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { MenuItemComponent } from './MenuItemComponent'
import { MenuItem } from '@/@types/menu'
interface SortableMenuTreeProps {
items: MenuItem[]
onItemsChange: (items: MenuItem[]) => void
isDesignMode: boolean
}
export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
items,
onItemsChange,
isDesignMode,
}) => {
const [activeItem, setActiveItem] = React.useState<MenuItem | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
// Flatten the tree structure to get all items with their paths
const flattenItems = (
items: MenuItem[],
parentPath: string[] = [],
): Array<{ item: MenuItem; path: number[] }> => {
const result: Array<{ item: MenuItem; path: number[] }> = []
items.forEach((item, index) => {
const currentPath = [...parentPath, index]
result.push({ item, path: currentPath })
if (item.children && item.children.length > 0) {
result.push(...flattenItems(item.children, currentPath))
}
})
return result
}
// Find item by ID in the tree
const findItemById = (items: MenuItem[], id: string): MenuItem | null => {
for (const item of items) {
if (item.id === id) {
return item
}
if (item.children) {
const found = findItemById(item.children, id)
if (found) return found
}
}
return null
}
// Remove item from tree by ID
const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => {
return items.reduce((acc: MenuItem[], item) => {
if (item.id === id) {
return acc // Skip this item (remove it)
}
const newItem = { ...item }
if (newItem.children && newItem.children.length > 0) {
newItem.children = removeItemFromTree(newItem.children, id)
}
acc.push(newItem)
return acc
}, [])
}
// Insert item at specific position
const insertItemAtPath = (
items: MenuItem[],
item: MenuItem,
targetPath: number[],
): MenuItem[] => {
if (targetPath.length === 1) {
const newItems = [...items]
newItems.splice(targetPath[0], 0, item)
return newItems
}
const [firstIndex, ...restPath] = targetPath
const newItems = [...items]
if (newItems[firstIndex]) {
newItems[firstIndex] = {
...newItems[firstIndex],
children: insertItemAtPath(newItems[firstIndex].children || [], item, restPath),
}
}
return newItems
}
// Get the path where an item should be inserted based on over item
const getInsertionPath = (
items: MenuItem[],
activeId: string,
overId: string,
): number[] | null => {
const flatItems = flattenItems(items)
const activeIndex = flatItems.findIndex(({ item }) => item.id === activeId)
const overIndex = flatItems.findIndex(({ item }) => item.id === overId)
if (overIndex === -1) return null
const overItem = flatItems[overIndex]
const insertPath = [...overItem.path]
// Aktif item, listedeki over item'den sonra geliyorsa, yukarı taşınıyordur → over item'ın yerine ekle
// Aktif item, listedeki over item'den önceyse, aşağı taşınıyordur → bir SONRASINA ekle ki yeniden aynı yere düşmesin
if (activeIndex < overIndex) {
insertPath[insertPath.length - 1] += 1
}
return insertPath
}
const updateOrderNumbers = (items: MenuItem[]): MenuItem[] => {
return items.map((item, index) => ({
...item,
order: index + 1,
children: item.children ? updateOrderNumbers(item.children) : [],
}))
}
const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const activeItem = findItemById(items, active.id as string)
setActiveItem(activeItem)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveItem(null)
if (!over || active.id === over.id || !isDesignMode) {
return
}
const activeId = active.id as string
const overId = over.id as string
const activeItem = findItemById(items, activeId)
if (!activeItem) return
// ⛳️ Kullanılması gereken liste: `items`
const insertionPath = getInsertionPath(items, activeId, overId)
if (!insertionPath) return
// Şimdi aktif elemanı çıkar
let newItems = removeItemFromTree(items, activeId)
// ve hedef konuma ekle
newItems = insertItemAtPath(newItems, activeItem, insertionPath)
// Sıra numaralarını güncelle
const finalItems = updateOrderNumbers(newItems)
onItemsChange(finalItems)
}
const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => {
return (
<MenuItemComponent key={item.id} item={item} isDesignMode={isDesignMode} depth={depth}>
{item.children && item.children.length > 0 && (
<div className="ml-4">
{item.children.map((child) => renderMenuItem(child, depth + 1))}
</div>
)}
</MenuItemComponent>
)
}
const allItems = flattenItems(items)
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={allItems.map(({ item }) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">{items.map((item) => renderMenuItem(item))}</div>
</SortableContext>
<DragOverlay>
{activeItem ? (
<MenuItemComponent
item={activeItem}
isDesignMode={isDesignMode}
depth={0}
isDragOverlay={true}
/>
) : null}
</DragOverlay>
</DndContext>
)
}