Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
VCCI-News
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Văn Hoàng
VCCI-News
Commits
cff4d2fb
Commit
cff4d2fb
authored
May 10, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
4489f507
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
496 additions
and
302 deletions
+496
-302
page.tsx
.../admin/header-config/[categoryId]/posts/[postId]/page.tsx
+24
-46
page.tsx
src/app/admin/header-config/[categoryId]/posts/page.tsx
+2
-70
header-category-stats.tsx
.../admin/header-config/components/header-category-stats.tsx
+15
-6
header-category-table.tsx
.../admin/header-config/components/header-category-table.tsx
+163
-140
page.tsx
src/app/admin/header-config/page.tsx
+1
-17
admin-stats-grid.tsx
src/components/admin/admin-stats-grid.tsx
+23
-13
admin-table-layout.tsx
src/components/admin/admin-table-layout.tsx
+57
-0
header-category-posts.ts
src/mockdata/header-category-posts.ts
+89
-5
header-config.ts
src/mockdata/header-config.ts
+122
-5
No files found.
src/app/admin/header-config/[categoryId]/posts/[postId]/page.tsx
View file @
cff4d2fb
...
...
@@ -15,15 +15,14 @@ import {
HEADER_CONFIG_STORAGE_KEY
,
HeaderCategoryItem
,
normalizeHeaderCategories
,
toSlug
,
}
from
'@/mockdata/header-config'
;
import
{
createHeaderCategoryPostId
,
EMPTY_HEADER_CATEGORY_POST_FORM
,
getHeaderCategoryPostSeed
,
HEADER_CATEGORY_POSTS_STORAGE_KEY
,
HeaderCategoryPostFormValues
,
HeaderCategoryPostItem
,
makeHeaderCategoryPostSlug
,
normalizeHeaderCategoryPosts
,
}
from
'@/mockdata/header-category-posts'
;
import
{
ArrowLeft
,
Save
}
from
'lucide-react'
;
...
...
@@ -97,14 +96,6 @@ function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
);
}
function
upsertPost
(
items
:
HeaderCategoryPostItem
[],
post
:
HeaderCategoryPostItem
)
{
const
exists
=
items
.
some
((
item
)
=>
item
.
id
===
post
.
id
);
return
normalizeHeaderCategoryPosts
(
exists
?
items
.
map
((
item
)
=>
(
item
.
id
===
post
.
id
?
post
:
item
))
:
[...
items
,
post
],
);
}
function
useHeaderCategoryPostsModule
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
HeaderCategoryPostItem
[]
>
([]);
const
[
isReady
,
setIsReady
]
=
React
.
useState
(
false
);
...
...
@@ -134,10 +125,10 @@ function useHeaderCategoryPostsModule() {
const
now
=
new
Date
().
toISOString
();
const
nextPost
:
HeaderCategoryPostItem
=
{
id
:
createHeaderCategoryPostId
()
,
id
:
`header-post-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
8
)}
`
,
category_id
:
categoryId
,
title
:
values
.
title
.
trim
(),
slug
:
values
.
slug
.
trim
()
||
makeHeaderCategoryPost
Slug
(
values
.
title
),
slug
:
values
.
slug
.
trim
()
||
to
Slug
(
values
.
title
),
excerpt
:
values
.
excerpt
.
trim
(),
content
:
values
.
content
.
trim
(),
thumbnail
:
values
.
thumbnail
.
trim
(),
...
...
@@ -147,35 +138,32 @@ function useHeaderCategoryPostsModule() {
updated_at
:
now
,
};
setItems
((
current
)
=>
upsertPost
(
current
,
nextPost
));
setItems
((
current
)
=>
normalizeHeaderCategoryPosts
([...
current
,
nextPost
]
));
return
nextPost
;
},
[],
);
const
updatePost
=
React
.
useCallback
((
postId
:
string
,
values
:
HeaderCategoryPostFormValues
)
=>
{
let
updatedPost
:
HeaderCategoryPostItem
|
null
=
null
;
setItems
((
current
)
=>
{
const
existing
=
current
.
find
((
item
)
=>
item
.
id
===
postId
);
if
(
!
existing
)
return
current
;
updatedPost
=
{
...
existing
,
title
:
values
.
title
.
trim
(),
slug
:
values
.
slug
.
trim
()
||
makeHeaderCategoryPostSlug
(
values
.
title
),
excerpt
:
values
.
excerpt
.
trim
(),
content
:
values
.
content
.
trim
(),
thumbnail
:
values
.
thumbnail
.
trim
(),
published_at
:
values
.
published_at
||
existing
.
published_at
,
is_active
:
values
.
is_active
,
updated_at
:
new
Date
().
toISOString
(),
};
return
upsertPost
(
current
,
updatedPost
);
});
return
updatedPost
;
setItems
((
current
)
=>
normalizeHeaderCategoryPosts
(
current
.
map
((
item
)
=>
item
.
id
===
postId
?
{
...
item
,
title
:
values
.
title
.
trim
(),
slug
:
values
.
slug
.
trim
()
||
toSlug
(
values
.
title
),
excerpt
:
values
.
excerpt
.
trim
(),
content
:
values
.
content
.
trim
(),
thumbnail
:
values
.
thumbnail
.
trim
(),
published_at
:
values
.
published_at
||
item
.
published_at
,
is_active
:
values
.
is_active
,
updated_at
:
new
Date
().
toISOString
(),
}
:
item
,
),
),
);
},
[]);
const
toFormValues
=
React
.
useCallback
(
...
...
@@ -313,17 +301,7 @@ export default function HeaderCategoryPostFormPage() {
setForm
((
previous
)
=>
({
...
previous
,
title
:
value
,
slug
:
value
.
normalize
(
'NFD'
)
.
replace
(
/
[\u
0300-
\u
036f
]
/g
,
''
)
.
replace
(
/đ/g
,
'd'
)
.
replace
(
/Đ/g
,
'D'
)
.
toLowerCase
()
.
trim
()
.
replace
(
/
[^
a-z0-9
\\
s-
]
/g
,
''
)
.
replace
(
/
\\
s+/g
,
'-'
)
.
replace
(
/-+/g
,
'-'
)
||
previous
.
slug
,
slug
:
toSlug
(
value
)
||
previous
.
slug
,
}));
}
;
...
...
src/app/admin/header-config/[categoryId]/posts/page.tsx
View file @
cff4d2fb
...
...
@@ -35,12 +35,9 @@ import {
normalizeHeaderCategories
,
}
from
'@/mockdata/header-config'
;
import
{
createHeaderCategoryPostId
,
getHeaderCategoryPostSeed
,
HEADER_CATEGORY_POSTS_STORAGE_KEY
,
HeaderCategoryPostFormValues
,
HeaderCategoryPostItem
,
makeHeaderCategoryPostSlug
,
normalizeHeaderCategoryPosts
,
}
from
'@/mockdata/header-category-posts'
;
import
{
...
...
@@ -120,14 +117,6 @@ function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
);
}
function
upsertPost
(
items
:
HeaderCategoryPostItem
[],
post
:
HeaderCategoryPostItem
)
{
const
exists
=
items
.
some
((
item
)
=>
item
.
id
===
post
.
id
);
return
normalizeHeaderCategoryPosts
(
exists
?
items
.
map
((
item
)
=>
(
item
.
id
===
post
.
id
?
post
:
item
))
:
[...
items
,
post
],
);
}
function
useHeaderCategoryPostsModule
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
HeaderCategoryPostItem
[]
>
([]);
const
[
isReady
,
setIsReady
]
=
React
.
useState
(
false
);
...
...
@@ -147,60 +136,6 @@ function useHeaderCategoryPostsModule() {
[
items
],
);
const
getPostById
=
React
.
useCallback
(
(
postId
:
string
)
=>
items
.
find
((
item
)
=>
item
.
id
===
postId
)
??
null
,
[
items
],
);
const
createPost
=
React
.
useCallback
(
(
categoryId
:
string
,
values
:
HeaderCategoryPostFormValues
)
=>
{
const
now
=
new
Date
().
toISOString
();
const
nextPost
:
HeaderCategoryPostItem
=
{
id
:
createHeaderCategoryPostId
(),
category_id
:
categoryId
,
title
:
values
.
title
.
trim
(),
slug
:
values
.
slug
.
trim
()
||
makeHeaderCategoryPostSlug
(
values
.
title
),
excerpt
:
values
.
excerpt
.
trim
(),
content
:
values
.
content
.
trim
(),
thumbnail
:
values
.
thumbnail
.
trim
(),
published_at
:
values
.
published_at
||
now
.
slice
(
0
,
10
),
is_active
:
values
.
is_active
,
created_at
:
now
,
updated_at
:
now
,
};
setItems
((
current
)
=>
upsertPost
(
current
,
nextPost
));
return
nextPost
;
},
[],
);
const
updatePost
=
React
.
useCallback
((
postId
:
string
,
values
:
HeaderCategoryPostFormValues
)
=>
{
let
updatedPost
:
HeaderCategoryPostItem
|
null
=
null
;
setItems
((
current
)
=>
{
const
existing
=
current
.
find
((
item
)
=>
item
.
id
===
postId
);
if
(
!
existing
)
return
current
;
updatedPost
=
{
...
existing
,
title
:
values
.
title
.
trim
(),
slug
:
values
.
slug
.
trim
()
||
makeHeaderCategoryPostSlug
(
values
.
title
),
excerpt
:
values
.
excerpt
.
trim
(),
content
:
values
.
content
.
trim
(),
thumbnail
:
values
.
thumbnail
.
trim
(),
published_at
:
values
.
published_at
||
existing
.
published_at
,
is_active
:
values
.
is_active
,
updated_at
:
new
Date
().
toISOString
(),
};
return
upsertPost
(
current
,
updatedPost
);
});
return
updatedPost
;
},
[]);
const
removePost
=
React
.
useCallback
((
postId
:
string
)
=>
{
setItems
((
current
)
=>
current
.
filter
((
item
)
=>
item
.
id
!==
postId
));
},
[]);
...
...
@@ -208,9 +143,6 @@ function useHeaderCategoryPostsModule() {
return
{
isReady
,
getPostsByCategory
,
getPostById
,
createPost
,
updatePost
,
removePost
,
};
}
...
...
@@ -417,13 +349,13 @@ export default function HeaderCategoryPostsPage() {
</
div
>
<
div
className=
"min-w-0 space-y-1"
>
<
p
className=
"truncate font-medium text-black"
>
{
post
.
title
}
</
p
>
<
p
className=
"line-clamp-2 text-sm text-gray-700"
>
{
post
.
excerpt
||
'
—
'
}
</
p
>
<
p
className=
"line-clamp-2 text-sm text-gray-700"
>
{
post
.
excerpt
||
'
-
'
}
</
p
>
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"text-center text-sm text-gray-700"
>
{
post
.
slug
}
</
TableCell
>
<
TableCell
className=
"text-center text-sm text-gray-700"
>
{
post
.
published_at
?
dayjs
(
post
.
published_at
).
format
(
'DD/MM/YYYY'
)
:
'
—
'
}
{
post
.
published_at
?
dayjs
(
post
.
published_at
).
format
(
'DD/MM/YYYY'
)
:
'
-
'
}
</
TableCell
>
<
TableCell
className=
"text-center"
>
{
post
.
is_active
?
(
...
...
src/app/admin/header-config/components/header-category-stats.tsx
View file @
cff4d2fb
...
...
@@ -8,22 +8,31 @@ interface HeaderCategoryStatsProps {
total
:
number
;
root
:
number
;
nested
:
number
;
grouped
:
number
;
}
export
function
HeaderCategoryStats
({
total
,
root
,
nested
,
grouped
,
}:
HeaderCategoryStatsProps
)
{
return
(
<
AdminStatsGrid
items=
{
[
{
label
:
"Tổng danh mục"
,
value
:
total
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>
},
{
label
:
"Danh mục cha"
,
value
:
root
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>
},
{
label
:
"Danh mục con"
,
value
:
nested
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>
},
{
label
:
"Có danh mục con"
,
value
:
grouped
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>
},
{
label
:
"Tổng danh mục"
,
value
:
total
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Danh mục cha"
,
value
:
root
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Danh mục con"
,
value
:
nested
,
icon
:
<
FolderTree
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
]
}
/>
);
...
...
src/app/admin/header-config/components/header-category-table.tsx
View file @
cff4d2fb
...
...
@@ -14,6 +14,7 @@ import {
Plus
,
Trash
,
}
from
"lucide-react"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
...
...
@@ -23,7 +24,6 @@ import {
DropdownMenuSeparator
,
DropdownMenuTrigger
,
}
from
"@/components/ui/dropdown-menu"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Skeleton
}
from
"@/components/ui/skeleton"
;
import
{
Table
,
...
...
@@ -45,14 +45,27 @@ interface HeaderCategoryTableProps {
expanded
:
Record
<
string
,
boolean
>
;
isLoading
:
boolean
;
searchValue
:
string
;
action
?:
React
.
ReactNode
;
onSearchChange
:
(
value
:
string
)
=>
void
;
onToggle
:
(
id
:
string
)
=>
void
;
onCreateRoot
:
()
=>
void
;
onCreateChild
:
(
item
:
HeaderCategoryTreeItem
)
=>
void
;
onEdit
:
(
item
:
HeaderCategoryTreeItem
)
=>
void
;
onDelete
:
(
item
:
HeaderCategoryTreeItem
)
=>
void
;
}
function
getDisplaySortOrder
(
item
:
HeaderCategoryFlatRow
,
rows
:
HeaderCategoryFlatRow
[])
{
if
(
!
item
.
parentId
)
{
return
String
(
item
.
sort_order
);
}
const
parent
=
rows
.
find
((
entry
)
=>
entry
.
id
===
item
.
parentId
);
if
(
!
parent
)
{
return
String
(
item
.
sort_order
);
}
return
`
${
parent
.
sort_order
}
-
${
item
.
sort_order
}
`
;
}
function
getTypeIcon
(
type
:
HeaderCategoryTreeItem
[
"type"
])
{
switch
(
type
)
{
case
"news"
:
...
...
@@ -80,10 +93,10 @@ function HeaderCategoryTableLoading() {
<
Skeleton
className=
"mx-auto h-7 w-28 rounded-full bg-[#063e8e]/15"
/>
</
TableCell
>
<
TableCell
className=
"w-[140px] text-center"
>
<
Skeleton
className=
"mx-auto h-8 w-1
0
rounded-full bg-[#063e8e]/15"
/>
<
Skeleton
className=
"mx-auto h-8 w-1
2
rounded-full bg-[#063e8e]/15"
/>
</
TableCell
>
<
TableCell
className=
"w-[280px] py-4"
>
<
Skeleton
className=
"h-4 w-52 bg-[#063e8e]/15"
/>
<
Skeleton
className=
"
mx-auto
h-4 w-52 bg-[#063e8e]/15"
/>
</
TableCell
>
<
TableCell
className=
"w-[120px] text-center"
>
<
Skeleton
className=
"mx-auto h-8 w-8 rounded-md bg-[#063e8e]/15"
/>
...
...
@@ -97,161 +110,171 @@ export function HeaderCategoryTable({
expanded
,
isLoading
,
searchValue
,
action
,
onSearchChange
,
onToggle
,
onCreateRoot
,
onCreateChild
,
onEdit
,
onDelete
,
}:
HeaderCategoryTableProps
)
{
return
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<
Input
value=
{
searchValue
}
placeholder=
"Tìm kiếm danh mục..."
onChange=
{
(
event
)
=>
onSearchChange
(
event
.
target
.
value
)
}
className=
"max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
{
action
}
</
div
>
<
div
className=
"overflow-hidden rounded-xl border border-[#063e8e]/15 bg-white shadow-sm"
>
<
Table
className=
"table-fixed"
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-[34%] py-4 pl-4 text-center text-white"
>
Tên danh mục
</
TableHead
>
<
TableHead
className=
"w-[180px] py-4 text-center text-white"
>
Thể loại
</
TableHead
>
<
TableHead
className=
"w-[140px] py-4 text-center text-white"
>
Thứ tự
</
TableHead
>
<
TableHead
className=
"w-[280px] py-4 text-center text-white"
>
Liên kết
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
<
AdminTableLayout
searchValue=
{
searchValue
}
searchPlaceholder=
"Tìm kiếm danh mục..."
actionLabel=
"Thêm danh mục"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
onSearchChange=
{
onSearchChange
}
onActionClick=
{
onCreateRoot
}
>
<
Table
className=
"table-fixed"
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-[34%] py-4 pl-4 text-center text-white"
>
Tên danh mục
</
TableHead
>
<
TableHead
className=
"w-[180px] py-4 text-center text-white"
>
Thể loại
</
TableHead
>
<
TableHead
className=
"w-[140px] py-4 text-center text-white"
>
Thứ tự
</
TableHead
>
<
TableHead
className=
"w-[280px] py-4 text-center text-white"
>
Liên kết
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
isLoading
?
(
<
HeaderCategoryTableLoading
/>
)
:
rows
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
5
}
className=
"py-12 text-center text-sm text-gray-700"
>
Không có danh mục nào phù hợp.
</
TableCell
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
isLoading
?
(
<
HeaderCategoryTableLoading
/>
)
:
rows
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
5
}
className=
"py-12 text-center text-sm text-gray-700"
>
Không có danh mục nào phù hợp.
</
TableCell
>
</
TableRow
>
)
:
(
rows
.
map
((
item
,
index
)
=>
{
const
hasChildren
=
rows
.
some
((
entry
)
=>
entry
.
parentId
===
item
.
id
);
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
canCreateChild
=
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
canManagePosts
=
item
.
type
===
"page"
||
item
.
type
===
"news"
||
item
.
type
===
"image"
;
const
createContentLabel
=
item
.
type
===
"image"
?
"Thêm ảnh"
:
"Thêm bài viết"
;
)
:
(
rows
.
map
((
item
,
index
)
=>
{
const
hasChildren
=
rows
.
some
((
entry
)
=>
entry
.
parentId
===
item
.
id
);
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
canCreateChild
=
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
canManagePosts
=
item
.
type
===
"page"
||
item
.
type
===
"news"
||
item
.
type
===
"image"
;
const
createContentLabel
=
item
.
type
===
"image"
?
"Thêm ảnh"
:
"Thêm bài viết"
;
return
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/[0.03]"
}
>
<
TableCell
className=
"w-[34%] py-4"
>
<
div
className=
"flex items-center"
style=
{
{
marginLeft
:
item
.
depth
*
24
}
}
>
{
hasChildren
?
(
<
button
type=
"button"
className=
"mr-2 rounded p-1 hover:bg-[#063e8e]/10"
onClick=
{
()
=>
onToggle
(
item
.
id
)
}
>
{
isExpanded
?
(
<
ChevronDown
className=
"h-4 w-4"
/>
)
:
(
<
ChevronRight
className=
"h-4 w-4"
/>
)
}
</
button
>
)
:
(
<
span
className=
"mr-2 w-6"
/>
)
}
return
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/[0.03]"
}
>
<
TableCell
className=
"w-[34%] py-4"
>
<
div
className=
"flex items-center"
style=
{
{
marginLeft
:
item
.
depth
*
24
}
}
>
{
hasChildren
?
(
<
button
type=
"button"
className=
"mr-2 rounded p-1 hover:bg-[#063e8e]/10"
onClick=
{
()
=>
onToggle
(
item
.
id
)
}
>
{
isExpanded
?
(
<
ChevronDown
className=
"h-4 w-4"
/>
)
:
(
<
ChevronRight
className=
"h-4 w-4"
/>
)
}
</
button
>
)
:
(
<
span
className=
"mr-2 w-6"
/>
)
}
<
div
className=
"mr-2"
>
{
getTypeIcon
(
item
.
type
)
}
</
div
>
<
div
className=
"truncate font-medium text-black"
>
{
item
.
name
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"w-[180px] text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
getHeaderCategoryTypeLabel
(
item
.
type
)
}
</
Badge
>
</
TableCell
>
<
TableCell
className=
"w-[140px] text-center font-medium text-black"
>
<
span
className=
{
item
.
parent_id
?
"inline-flex min-w-8 items-center justify-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700"
:
"inline-flex min-w-8 items-center justify-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]"
}
>
{
item
.
sort_order
}
<
div
className=
"mr-2"
>
{
getTypeIcon
(
item
.
type
)
}
</
div
>
<
div
className=
"truncate font-medium text-black"
>
{
item
.
name
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"w-[180px] text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
getHeaderCategoryTypeLabel
(
item
.
type
)
}
</
Badge
>
</
TableCell
>
<
TableCell
className=
"w-[140px] text-center font-medium text-black"
>
<
span
className=
{
item
.
parent_id
?
"inline-flex min-w-8 items-center justify-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700"
:
"inline-flex min-w-8 items-center justify-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]"
}
>
{
getDisplaySortOrder
(
item
,
rows
)
}
</
span
>
</
TableCell
>
<
TableCell
className=
"w-[280px] text-sm text-gray-700"
>
<
div
className=
"mx-auto flex max-w-[220px] items-center justify-center gap-2"
>
<
span
className=
"block max-w-[180px] truncate"
>
{
item
.
static_link
||
"-"
}
</
span
>
</
TableCell
>
<
TableCell
className=
"w-[280px] text-sm text-gray-700"
>
<
div
className=
"mx-auto flex max-w-[220px] items-center justify-center gap-2"
>
<
span
className=
"block max-w-[180px] truncate"
>
{
item
.
static_link
||
"—"
}
</
span
>
{
item
.
static_link
?
(
<
ExternalLink
className=
"h-3.5 w-3.5 shrink-0 text-[#063e8e]"
/>
)
:
null
}
</
div
>
</
TableCell
>
<
TableCell
className=
"w-[120px] text-center"
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
Button
variant=
"ghost"
className=
"h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<
MoreHorizontal
className=
"h-4 w-4"
/>
</
Button
>
</
DropdownMenuTrigger
>
<
DropdownMenuContent
align=
"end"
>
{
item
.
static_link
?
(
<
ExternalLink
className=
"h-3.5 w-3.5 shrink-0 text-[#063e8e]"
/>
)
:
null
}
</
div
>
</
TableCell
>
<
TableCell
className=
"w-[120px] text-center"
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
Button
variant=
"ghost"
className=
"h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<
MoreHorizontal
className=
"h-4 w-4"
/>
</
Button
>
</
DropdownMenuTrigger
>
<
DropdownMenuContent
align=
"end"
>
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
onEdit
(
item
)
}
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Chỉnh sửa
</
DropdownMenuItem
>
{
canManagePosts
?
(
<
DropdownMenuItem
asChild
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
onEdit
(
item
)
}
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Chỉnh sửa
</
DropdownMenuItem
>
{
canManagePosts
?
(
<
DropdownMenuItem
asChild
className=
"text-gray-700 focus:text-[#063e8e]"
>
<
Link
href=
{
`/admin/header-config/${item.id}/posts/new`
}
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
{
createContentLabel
}
</
Link
>
</
DropdownMenuItem
>
)
:
null
}
{
canCreateChild
?
(
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
onCreateChild
(
item
)
}
>
<
Link
href=
{
`/admin/header-config/${item.id}/posts/new`
}
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thêm danh mục con
</
DropdownMenuItem
>
)
:
null
}
{
createContentLabel
}
</
Link
>
</
DropdownMenuItem
>
)
:
null
}
<
DropdownMenuSeparator
/>
{
canCreateChild
?
(
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
on
Delete
(
item
)
}
onClick=
{
()
=>
on
CreateChild
(
item
)
}
>
<
Trash
className=
"mr-2 h-4 w-4"
/>
Xóa
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thêm danh mục con
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
TableCell
>
</
TableRow
>
);
})
)
}
</
TableBody
>
</
Table
>
</
div
>
</
div
>
)
:
null
}
<
DropdownMenuSeparator
/>
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
onDelete
(
item
)
}
>
<
Trash
className=
"mr-2 h-4 w-4"
/>
Xóa
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
TableCell
>
</
TableRow
>
);
})
)
}
</
TableBody
>
</
Table
>
</
AdminTableLayout
>
);
}
src/app/admin/header-config/page.tsx
View file @
cff4d2fb
'use client'
;
import
React
from
'react'
;
import
{
Plus
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
import
{
HeaderCategoryDeleteDialog
,
...
...
@@ -12,7 +11,6 @@ import {
HeaderCategoryStats
,
HeaderCategoryTable
,
}
from
'./components'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
buildHeaderCategoryTree
,
createHeaderCategoryId
,
...
...
@@ -252,11 +250,6 @@ export default function HeaderConfigPage() {
[
tree
],
);
const
groupedCount
=
React
.
useMemo
(
()
=>
flatRows
.
filter
((
item
)
=>
item
.
children
.
length
>
0
).
length
,
[
flatRows
],
);
const
editingItem
=
React
.
useMemo
(
()
=>
flatRows
.
find
((
item
)
=>
item
.
id
===
formValues
.
id
)
??
null
,
[
flatRows
,
formValues
.
id
],
...
...
@@ -347,7 +340,6 @@ export default function HeaderConfigPage() {
total=
{
flatRows
.
length
}
root=
{
flatRows
.
filter
((
item
)
=>
!
item
.
parentId
).
length
}
nested=
{
flatRows
.
filter
((
item
)
=>
item
.
parentId
).
length
}
grouped=
{
groupedCount
}
/>
<
HeaderCategoryTable
...
...
@@ -355,19 +347,11 @@ export default function HeaderConfigPage() {
expanded=
{
expanded
}
isLoading=
{
!
isReady
}
searchValue=
{
search
}
action=
{
<
Button
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
openCreateRoot
}
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thêm danh mục
</
Button
>
}
onSearchChange=
{
setSearch
}
onToggle=
{
(
id
)
=>
setExpanded
((
previous
)
=>
({
...
previous
,
[
id
]:
!
(
previous
[
id
]
??
true
)
}))
}
onCreateRoot=
{
openCreateRoot
}
onCreateChild=
{
openCreateChild
}
onEdit=
{
openEdit
}
onDelete=
{
setDeleteTarget
}
...
...
src/components/admin/admin-stats-grid.tsx
View file @
cff4d2fb
"use client"
;
import
*
as
React
from
"react"
;
import
{
Card
,
CardContent
,
CardHeader
,
CardTitle
}
from
"@/components/ui/card"
;
interface
AdminStatsGridItem
{
label
:
string
;
...
...
@@ -15,20 +14,31 @@ interface AdminStatsGridProps {
}
export
function
AdminStatsGrid
({
items
,
className
}:
AdminStatsGridProps
)
{
const
gridClassName
=
className
??
(
items
.
length
===
3
?
"grid grid-cols-1 gap-4 md:grid-cols-3"
:
"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"
);
return
(
<
div
className=
{
className
??
"grid grid-cols-2 gap-4 lg:grid-cols-4"
}
>
<
div
className=
{
gridClassName
}
>
{
items
.
map
((
item
)
=>
(
<
Card
key=
{
item
.
label
}
className=
"border-slate-200 shadow-sm"
>
<
CardHeader
className=
"flex flex-row items-center justify-between space-y-0 pb-2"
>
<
CardTitle
className=
"text-sm font-medium text-gray-700"
>
{
item
.
label
}
</
CardTitle
>
{
item
.
icon
??
null
}
</
CardHeader
>
<
CardContent
>
<
div
className=
"text-2xl font-bold text-black"
>
{
item
.
value
}
</
div
>
</
CardContent
>
</
Card
>
<
div
key=
{
item
.
label
}
className=
"rounded-2xl border border-[#063e8e]/15 bg-white px-5 py-4 shadow-sm"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
className=
"space-y-2"
>
<
p
className=
"text-sm font-medium text-gray-700"
>
{
item
.
label
}
</
p
>
<
div
className=
"text-3xl font-semibold leading-none text-black"
>
{
item
.
value
}
</
div
>
</
div
>
{
item
.
icon
?
(
<
div
className=
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10"
>
{
item
.
icon
}
</
div
>
)
:
null
}
</
div
>
</
div
>
))
}
</
div
>
);
...
...
src/components/admin/admin-table-layout.tsx
0 → 100644
View file @
cff4d2fb
"use client"
;
import
*
as
React
from
"react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Button
}
from
"@/components/ui/button"
;
interface
AdminTableLayoutProps
{
searchValue
:
string
;
searchPlaceholder
?:
string
;
actionLabel
?:
string
;
actionIcon
?:
React
.
ReactNode
;
actionDisabled
?:
boolean
;
children
:
React
.
ReactNode
;
onSearchChange
:
(
value
:
string
)
=>
void
;
onActionClick
?:
()
=>
void
;
}
export
function
AdminTableLayout
({
searchValue
,
searchPlaceholder
=
"Tìm kiếm..."
,
actionLabel
,
actionIcon
,
actionDisabled
=
false
,
children
,
onSearchChange
,
onActionClick
,
}:
AdminTableLayoutProps
)
{
return
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<
Input
value=
{
searchValue
}
placeholder=
{
searchPlaceholder
}
onChange=
{
(
event
)
=>
onSearchChange
(
event
.
target
.
value
)
}
className=
"max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
{
actionLabel
?
(
<
Button
type=
"button"
disabled=
{
actionDisabled
}
onClick=
{
onActionClick
}
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{
actionIcon
??
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
{
actionLabel
}
</
Button
>
)
:
null
}
</
div
>
<
div
className=
"overflow-hidden rounded-xl border border-[#063e8e]/15 bg-white shadow-sm"
>
{
children
}
</
div
>
</
div
>
);
}
src/mockdata/header-category-posts.ts
View file @
cff4d2fb
...
...
@@ -19,6 +19,88 @@ export interface HeaderCategoryPostItem {
export
const
HEADER_CATEGORY_POSTS_STORAGE_KEY
=
"vcci-news.header-category-posts.data.v1"
;
function
normalizeVietnameseText
(
value
:
string
)
{
return
value
.
replace
(
/Ä‘/g
,
"đ"
)
.
replace
(
/Ä/g
,
"Đ"
)
.
replace
(
/Ã /g
,
"à"
)
.
replace
(
/á/g
,
"á"
)
.
replace
(
/ả/g
,
"ả"
)
.
replace
(
/ã/g
,
"ã"
)
.
replace
(
/ạ/g
,
"ạ"
)
.
replace
(
/ă/g
,
"ă"
)
.
replace
(
/â/g
,
"â"
)
.
replace
(
/è/g
,
"è"
)
.
replace
(
/é/g
,
"é"
)
.
replace
(
/ẻ/g
,
"ẻ"
)
.
replace
(
/ẽ/g
,
"ẽ"
)
.
replace
(
/ẹ/g
,
"ẹ"
)
.
replace
(
/ê/g
,
"ê"
)
.
replace
(
/ì/g
,
"ì"
)
.
replace
(
/Ã/g
,
"í"
)
.
replace
(
/Ä©/g
,
"ĩ"
)
.
replace
(
/ò/g
,
"ò"
)
.
replace
(
/ó/g
,
"ó"
)
.
replace
(
/õ/g
,
"õ"
)
.
replace
(
/ô/g
,
"ô"
)
.
replace
(
/Æ¡/g
,
"ơ"
)
.
replace
(
/ù/g
,
"ù"
)
.
replace
(
/ú/g
,
"ú"
)
.
replace
(
/Å©/g
,
"ũ"
)
.
replace
(
/ư/g
,
"ư"
)
.
replace
(
/á»/g
,
"ề"
)
.
replace
(
/ế/g
,
"ế"
)
.
replace
(
/ể/g
,
"ể"
)
.
replace
(
/á»…/g
,
"ễ"
)
.
replace
(
/ệ/g
,
"ệ"
)
.
replace
(
/á»/g
,
"ờ"
)
.
replace
(
/á»›/g
,
"ớ"
)
.
replace
(
/ở/g
,
"ở"
)
.
replace
(
/ỡ/g
,
"ỡ"
)
.
replace
(
/ợ/g
,
"ợ"
)
.
replace
(
/ừ/g
,
"ừ"
)
.
replace
(
/ứ/g
,
"ứ"
)
.
replace
(
/á»/g
,
"ử"
)
.
replace
(
/ữ/g
,
"ữ"
)
.
replace
(
/á»±/g
,
"ự"
)
.
replace
(
/ỳ/g
,
"ỳ"
)
.
replace
(
/ý/g
,
"ý"
)
.
replace
(
/á»·/g
,
"ỷ"
)
.
replace
(
/ỹ/g
,
"ỹ"
)
.
replace
(
/ỵ/g
,
"ỵ"
)
.
replace
(
/ổ/g
,
"ổ"
)
.
replace
(
/ố/g
,
"ố"
)
.
replace
(
/ồ/g
,
"ồ"
)
.
replace
(
/á»—/g
,
"ỗ"
)
.
replace
(
/á»™/g
,
"ộ"
)
.
replace
(
/ầ/g
,
"ầ"
)
.
replace
(
/ấ/g
,
"ấ"
)
.
replace
(
/ẩ/g
,
"ẩ"
)
.
replace
(
/ẫ/g
,
"ẫ"
)
.
replace
(
/áº/g
,
"ậ"
)
.
replace
(
/ằ/g
,
"ằ"
)
.
replace
(
/ắ/g
,
"ắ"
)
.
replace
(
/ẳ/g
,
"ẳ"
)
.
replace
(
/ẵ/g
,
"ẵ"
)
.
replace
(
/ặ/g
,
"ặ"
)
.
replace
(
/á»/g
,
"ỏ"
)
.
replace
(
/á»/g
,
"ọ"
)
.
replace
(
/á»§/g
,
"ủ"
)
.
replace
(
/ụ/g
,
"ụ"
)
.
replace
(
/ỉ/g
,
"ỉ"
)
.
replace
(
/ị/g
,
"ị"
)
.
replace
(
/á»i/g
,
"ời"
);
}
function
normalizePostItem
(
item
:
HeaderCategoryPostItem
):
HeaderCategoryPostItem
{
return
{
...
item
,
title
:
normalizeVietnameseText
(
item
.
title
),
excerpt
:
normalizeVietnameseText
(
item
.
excerpt
),
content
:
normalizeVietnameseText
(
item
.
content
),
};
}
export
const
headerCategoryPostSeed
:
HeaderCategoryPostItem
[]
=
[
{
id
:
"header-post-intro-about"
,
...
...
@@ -124,12 +206,14 @@ export const EMPTY_HEADER_CATEGORY_POST_FORM: HeaderCategoryPostFormValues = {
};
export
function
normalizeHeaderCategoryPosts
(
items
:
HeaderCategoryPostItem
[])
{
return
[...
items
].
sort
((
left
,
right
)
=>
{
const
leftTime
=
new
Date
(
left
.
published_at
||
left
.
updated_at
).
getTime
();
const
rightTime
=
new
Date
(
right
.
published_at
||
right
.
updated_at
).
getTime
();
return
[...
items
]
.
map
((
item
)
=>
normalizePostItem
(
item
))
.
sort
((
left
,
right
)
=>
{
const
leftTime
=
new
Date
(
left
.
published_at
||
left
.
updated_at
).
getTime
();
const
rightTime
=
new
Date
(
right
.
published_at
||
right
.
updated_at
).
getTime
();
return
rightTime
-
leftTime
||
right
.
updated_at
.
localeCompare
(
left
.
updated_at
);
});
return
rightTime
-
leftTime
||
right
.
updated_at
.
localeCompare
(
left
.
updated_at
);
});
}
export
function
createHeaderCategoryPostId
()
{
...
...
src/mockdata/header-config.ts
View file @
cff4d2fb
...
...
@@ -157,8 +157,122 @@ export const headerArticleCategoryOptions: HeaderArticleCategoryOption[] = [
{
id
:
"cat-gallery"
,
name
:
"Ảnh nổi bật"
},
];
export
function
toSlug
(
value
:
string
)
{
function
normalizeVietnameseText
(
value
:
string
)
{
return
value
.
replace
(
/Tìm/g
,
"Tìm"
)
.
replace
(
/Tên/g
,
"Tên"
)
.
replace
(
/Tổng/g
,
"Tổng"
)
.
replace
(
/Thể/g
,
"Thể"
)
.
replace
(
/Thứ/g
,
"Thứ"
)
.
replace
(
/Liên/g
,
"Liên"
)
.
replace
(
/Không/g
,
"Không"
)
.
replace
(
/Danh mục/g
,
"Danh mục"
)
.
replace
(
/danh mục/g
,
"danh mục"
)
.
replace
(
/Bà i viết/g
,
"Bài viết"
)
.
replace
(
/Tin tức/g
,
"Tin tức"
)
.
replace
(
/Ảnh/g
,
"Ảnh"
)
.
replace
(
/Giới thiệu/g
,
"Giới thiệu"
)
.
replace
(
/Vá»/g
,
"Về"
)
.
replace
(
/Cơ cấu tổ chức/g
,
"Cơ cấu tổ chức"
)
.
replace
(
/Hoạt động/g
,
"Hoạt động"
)
.
replace
(
/Sự kiện/g
,
"Sự kiện"
)
.
replace
(
/Thư viện ảnh/g
,
"Thư viện ảnh"
)
.
replace
(
/nổi báºt/g
,
"nổi bật"
)
.
replace
(
/Nhóm/g
,
"Nhóm"
)
.
replace
(
/ná»™i dung/g
,
"nội dung"
)
.
replace
(
/thông tin/g
,
"thông tin"
)
.
replace
(
/tổng hợp/g
,
"tổng hợp"
)
.
replace
(
/ChÃnh sách/g
,
"Chính sách"
)
.
replace
(
/Ä‘/g
,
"đ"
)
.
replace
(
/Ä/g
,
"Đ"
)
.
replace
(
/Ã /g
,
"à"
)
.
replace
(
/á/g
,
"á"
)
.
replace
(
/ả/g
,
"ả"
)
.
replace
(
/ã/g
,
"ã"
)
.
replace
(
/ạ/g
,
"ạ"
)
.
replace
(
/ă/g
,
"ă"
)
.
replace
(
/ằ/g
,
"ằ"
)
.
replace
(
/ắ/g
,
"ắ"
)
.
replace
(
/ẳ/g
,
"ẳ"
)
.
replace
(
/ẵ/g
,
"ẵ"
)
.
replace
(
/ặ/g
,
"ặ"
)
.
replace
(
/â/g
,
"â"
)
.
replace
(
/ầ/g
,
"ầ"
)
.
replace
(
/ấ/g
,
"ấ"
)
.
replace
(
/ẩ/g
,
"ẩ"
)
.
replace
(
/ẫ/g
,
"ẫ"
)
.
replace
(
/áº/g
,
"ậ"
)
.
replace
(
/è/g
,
"è"
)
.
replace
(
/é/g
,
"é"
)
.
replace
(
/ẻ/g
,
"ẻ"
)
.
replace
(
/ẽ/g
,
"ẽ"
)
.
replace
(
/ẹ/g
,
"ẹ"
)
.
replace
(
/ê/g
,
"ê"
)
.
replace
(
/á»/g
,
"ề"
)
.
replace
(
/ế/g
,
"ế"
)
.
replace
(
/ể/g
,
"ể"
)
.
replace
(
/á»…/g
,
"ễ"
)
.
replace
(
/ệ/g
,
"ệ"
)
.
replace
(
/ì/g
,
"ì"
)
.
replace
(
/Ã/g
,
"í"
)
.
replace
(
/ỉ/g
,
"ỉ"
)
.
replace
(
/Ä©/g
,
"ĩ"
)
.
replace
(
/ị/g
,
"ị"
)
.
replace
(
/ò/g
,
"ò"
)
.
replace
(
/ó/g
,
"ó"
)
.
replace
(
/á»/g
,
"ỏ"
)
.
replace
(
/õ/g
,
"õ"
)
.
replace
(
/á»/g
,
"ọ"
)
.
replace
(
/ô/g
,
"ô"
)
.
replace
(
/ồ/g
,
"ồ"
)
.
replace
(
/ố/g
,
"ố"
)
.
replace
(
/ổ/g
,
"ổ"
)
.
replace
(
/á»—/g
,
"ỗ"
)
.
replace
(
/á»™/g
,
"ộ"
)
.
replace
(
/Æ¡/g
,
"ơ"
)
.
replace
(
/á»/g
,
"ờ"
)
.
replace
(
/á»›/g
,
"ớ"
)
.
replace
(
/ở/g
,
"ở"
)
.
replace
(
/ỡ/g
,
"ỡ"
)
.
replace
(
/ợ/g
,
"ợ"
)
.
replace
(
/ù/g
,
"ù"
)
.
replace
(
/ú/g
,
"ú"
)
.
replace
(
/á»§/g
,
"ủ"
)
.
replace
(
/Å©/g
,
"ũ"
)
.
replace
(
/ụ/g
,
"ụ"
)
.
replace
(
/ư/g
,
"ư"
)
.
replace
(
/ừ/g
,
"ừ"
)
.
replace
(
/ứ/g
,
"ứ"
)
.
replace
(
/á»/g
,
"ử"
)
.
replace
(
/ữ/g
,
"ữ"
)
.
replace
(
/á»±/g
,
"ự"
)
.
replace
(
/ỳ/g
,
"ỳ"
)
.
replace
(
/ý/g
,
"ý"
)
.
replace
(
/á»·/g
,
"ỷ"
)
.
replace
(
/ỹ/g
,
"ỹ"
)
.
replace
(
/ỵ/g
,
"ỵ"
);
}
function
normalizeHeaderCategoryText
<
T
extends
HeaderCategoryItem
|
HeaderArticleCategoryOption
>
(
item
:
T
,
):
T
{
const
normalized
=
{
...
item
,
name
:
normalizeVietnameseText
(
item
.
name
),
}
as
T
;
if
(
"description"
in
item
&&
typeof
item
.
description
===
"string"
)
{
return
{
...
normalized
,
description
:
normalizeVietnameseText
(
item
.
description
),
};
}
return
normalized
;
}
export
function
toSlug
(
value
:
string
)
{
return
normalizeVietnameseText
(
value
)
.
normalize
(
"NFD"
)
.
replace
(
/
[\u
0300-
\u
036f
]
/g
,
""
)
.
replace
(
/đ/g
,
"d"
)
...
...
@@ -206,11 +320,14 @@ function assignLevel(item: HeaderCategoryItem, items: HeaderCategoryItem[]) {
}
export
function
normalizeHeaderCategories
(
items
:
HeaderCategoryItem
[])
{
const
sanitizedItems
=
items
.
map
((
item
)
=>
normalizeHeaderCategoryText
(
item
));
const
parentIds
=
new
Set
(
items
.
filter
((
item
)
=>
item
.
parent_id
).
map
((
item
)
=>
item
.
parent_id
as
string
),
sanitizedItems
.
filter
((
item
)
=>
item
.
parent_id
)
.
map
((
item
)
=>
item
.
parent_id
as
string
),
);
return
i
tems
.
map
((
item
)
=>
{
return
sanitizedI
tems
.
map
((
item
)
=>
{
const
next
=
{
...
item
};
if
(
parentIds
.
has
(
next
.
id
))
{
...
...
@@ -218,8 +335,8 @@ export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
next
.
category_ids
=
[];
}
next
.
level
=
assignLevel
(
next
,
i
tems
);
next
.
static_link
=
next
.
slug
===
""
&&
!
next
.
parent_id
?
"/"
:
buildStaticLink
(
next
,
i
tems
);
next
.
level
=
assignLevel
(
next
,
sanitizedI
tems
);
next
.
static_link
=
next
.
slug
===
""
&&
!
next
.
parent_id
?
"/"
:
buildStaticLink
(
next
,
sanitizedI
tems
);
next
.
is_article
=
next
.
type
===
"news"
;
if
(
next
.
type
!==
"news"
)
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment