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
080821e5
Commit
080821e5
authored
May 11, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
8d514056
Changes
18
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
1896 additions
and
857 deletions
+1896
-857
page.tsx
src/app/admin/categories/page.tsx
+0
-14
page.tsx
src/app/admin/dashboard/page.tsx
+0
-175
page.tsx
src/app/admin/emails/page.tsx
+0
-127
header-category-table.tsx
.../admin/header-config/components/header-category-table.tsx
+1
-1
page.tsx
src/app/admin/members/[id]/page.tsx
+11
-0
page.tsx
src/app/admin/members/fields/page.tsx
+259
-0
page.tsx
src/app/admin/members/page.tsx
+296
-129
page.tsx
src/app/admin/members/regions/page.tsx
+259
-0
page.tsx
src/app/admin/partners/page.tsx
+0
-138
page.tsx
src/app/admin/videos/page.tsx
+326
-0
page.tsx
src/app/admin/website-config/page.tsx
+0
-244
admin-table-layout.tsx
src/components/admin/admin-table-layout.tsx
+18
-11
member-form.tsx
src/components/admin/member-form.tsx
+406
-0
rich-text-editor.tsx
src/components/admin/rich-text-editor.tsx
+1
-1
admin-header.tsx
src/components/shared/admin-header.tsx
+3
-2
admin-sidebar.tsx
src/components/shared/admin-sidebar.tsx
+28
-15
members.ts
src/mockdata/members.ts
+228
-0
videos.ts
src/mockdata/videos.ts
+60
-0
No files found.
src/app/admin/categories/page.tsx
deleted
100644 → 0
View file @
8d514056
'use client'
;
import
{
useEffect
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
export
default
function
CategoriesPage
()
{
const
router
=
useRouter
();
useEffect
(()
=>
{
router
.
replace
(
'/admin/header-config'
);
},
[
router
]);
return
null
;
}
src/app/admin/dashboard/page.tsx
deleted
100644 → 0
View file @
8d514056
'use client'
;
import
React
from
'react'
;
import
Link
from
'next/link'
;
import
{
useGetNewsAdmin
}
from
'@/api/endpoints/news'
;
import
{
useGetOrganizations
}
from
'@/api/endpoints/organizations'
;
import
{
useGetContact
}
from
'@/api/endpoints/contact'
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
'@/api/endpoints/news-page-config'
;
import
{
GetNewsResponseType
}
from
'@/api/types/news'
;
import
{
GetNewsPageConfigResponseType
}
from
'@/api/types/news-page-config'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
Card
,
CardContent
,
CardHeader
,
CardTitle
}
from
'@/components/ui/card'
;
import
{
Skeleton
}
from
'@/components/ui/skeleton'
;
import
{
ArrowRight
,
BarChart3
,
Building2
,
FileText
,
Layers
,
Mail
,
Newspaper
,
Users
,
}
from
'lucide-react'
;
function
extractCount
(
value
:
unknown
,
):
number
|
undefined
{
if
(
!
value
||
typeof
value
!==
'object'
)
return
undefined
;
const
source
=
value
as
{
responseData
?:
{
count
?:
number
};
data
?:
{
count
?:
number
;
responseData
?:
{
count
?:
number
}
};
};
return
(
source
.
responseData
?.
count
??
source
.
data
?.
responseData
?.
count
??
source
.
data
?.
count
);
}
interface
StatCardProps
{
title
:
string
;
value
:
number
|
string
|
undefined
;
icon
:
React
.
ReactNode
;
href
:
string
;
color
:
string
;
isLoading
?:
boolean
;
}
function
StatCard
({
title
,
value
,
icon
,
href
,
color
,
isLoading
}:
StatCardProps
)
{
return
(
<
Link
href=
{
href
}
>
<
Card
className=
"cursor-pointer transition-shadow hover:shadow-md"
>
<
CardContent
className=
"p-6"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex-1"
>
<
p
className=
"text-sm font-medium text-gray-500"
>
{
title
}
</
p
>
{
isLoading
?
(
<
Skeleton
className=
"mt-2 h-8 w-20"
/>
)
:
(
<
p
className=
"mt-1 text-3xl font-bold text-gray-900"
>
{
value
??
'—'
}
</
p
>
)
}
</
div
>
<
div
className=
{
`flex h-14 w-14 items-center justify-center rounded-2xl text-white shadow-sm ${color}`
}
>
{
icon
}
</
div
>
</
div
>
<
div
className=
"mt-4 flex items-center gap-1 text-xs text-[#063e8e] opacity-0 transition-opacity group-hover:opacity-100"
>
<
span
>
Xem chi tiết
</
span
>
<
ArrowRight
className=
"h-3 w-3"
/>
</
div
>
</
CardContent
>
</
Card
>
</
Link
>
);
}
export
default
function
DashboardPage
()
{
const
{
data
:
newsData
,
isLoading
:
newsLoading
}
=
useGetNewsAdmin
<
GetNewsResponseType
>
({
pageSize
:
'1'
});
const
{
data
:
configData
,
isLoading
:
configLoading
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
();
const
{
data
:
orgData
,
isLoading
:
orgLoading
}
=
useGetOrganizations
({
pageSize
:
'1'
});
const
{
data
:
contactData
,
isLoading
:
contactLoading
}
=
useGetContact
();
const
stats
=
[
{
title
:
'Tổng bài viết'
,
value
:
newsData
?.
responseData
?.
count
,
icon
:
<
Newspaper
className=
"h-7 w-7"
/>,
href
:
'/admin/news'
,
color
:
'bg-[#063e8e]'
,
isLoading
:
newsLoading
,
},
{
title
:
'Cấu hình Danh mục'
,
value
:
configData
?.
responseData
?
'Đã cấu hình'
:
'—'
,
icon
:
<
Layers
className=
"h-7 w-7"
/>,
href
:
'/admin/header-config'
,
color
:
'bg-violet-500'
,
isLoading
:
configLoading
,
},
{
title
:
'Hội viên'
,
value
:
extractCount
(
orgData
),
icon
:
<
Users
className=
"h-7 w-7"
/>,
href
:
'/admin/members'
,
color
:
'bg-orange-500'
,
isLoading
:
orgLoading
,
},
{
title
:
'Liên hệ / Email'
,
value
:
extractCount
(
contactData
),
icon
:
<
Mail
className=
"h-7 w-7"
/>,
href
:
'/admin/emails'
,
color
:
'bg-pink-500'
,
isLoading
:
contactLoading
,
},
];
const
quickLinks
=
[
{
label
:
'Thêm bài viết mới'
,
href
:
'/admin/news/new'
,
icon
:
<
FileText
className=
"h-4 w-4"
/>
},
{
label
:
'Cấu hình menu Header'
,
href
:
'/admin/header-config'
,
icon
:
<
Layers
className=
"h-4 w-4"
/>
},
{
label
:
'Quản lý Hội viên'
,
href
:
'/admin/members'
,
icon
:
<
Users
className=
"h-4 w-4"
/>
},
{
label
:
'Đối tác'
,
href
:
'/admin/partners'
,
icon
:
<
Building2
className=
"h-4 w-4"
/>
},
{
label
:
'Thông tin website'
,
href
:
'/admin/website-config'
,
icon
:
<
BarChart3
className=
"h-4 w-4"
/>
},
];
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
h2
className=
"text-3xl font-bold tracking-tight text-gray-900"
>
Tổng quan hệ thống
</
h2
>
<
p
className=
"mt-1 text-sm font-medium text-gray-600"
>
Chào mừng trở lại! Đây là tóm tắt hoạt động của VCCI News.
</
p
>
</
div
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/30 text-[#063e8e]"
>
Cập nhật:
{
new
Date
().
toLocaleDateString
(
'vi-VN'
)
}
</
Badge
>
</
div
>
<
div
className=
"grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4"
>
{
stats
.
map
((
item
)
=>
(
<
StatCard
key=
{
item
.
href
}
{
...
item
}
/>
))
}
</
div
>
<
Card
>
<
CardHeader
>
<
CardTitle
className=
"text-base font-semibold text-gray-800"
>
Truy cập nhanh
</
CardTitle
>
</
CardHeader
>
<
CardContent
>
<
div
className=
"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
>
{
quickLinks
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
href
}
href=
{
item
.
href
}
className=
"flex flex-col items-center gap-2 rounded-xl border border-[#063e8e]/15 p-4 text-center text-sm text-gray-700 transition-all hover:border-[#063e8e]/40 hover:bg-[#063e8e]/5 hover:text-[#063e8e]"
>
<
div
className=
"flex h-10 w-10 items-center justify-center rounded-full bg-[#063e8e]/10 text-[#063e8e]"
>
{
item
.
icon
}
</
div
>
<
span
className=
"text-xs font-medium leading-tight"
>
{
item
.
label
}
</
span
>
</
Link
>
))
}
</
div
>
</
CardContent
>
</
Card
>
</
div
>
);
}
src/app/admin/emails/page.tsx
deleted
100644 → 0
View file @
8d514056
'use client'
;
import
React
,
{
useState
}
from
'react'
;
import
{
useGetContact
}
from
'@/api/endpoints/contact'
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
'@/components/ui/table'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Search
,
Mail
}
from
'lucide-react'
;
import
{
Skeleton
}
from
'@/components/ui/skeleton'
;
import
dayjs
from
'dayjs'
;
export
default
function
EmailsPage
()
{
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
pageSize
=
20
;
const
{
data
,
isLoading
}
=
useGetContact
({}
as
any
);
const
allRows
=
(
data
as
any
)?.
responseData
?.
rows
??
(
data
as
any
)?.
data
?.
rows
??
[];
const
filtered
=
search
?
allRows
.
filter
(
(
r
:
any
)
=>
r
.
email
?.
includes
(
search
)
||
r
.
full_name
?.
toLowerCase
().
includes
(
search
.
toLowerCase
())
||
r
.
organization_name
?.
toLowerCase
().
includes
(
search
.
toLowerCase
()),
)
:
allRows
;
const
total
=
filtered
.
length
;
const
totalPages
=
Math
.
ceil
(
total
/
pageSize
);
const
rows
=
filtered
.
slice
((
page
-
1
)
*
pageSize
,
page
*
pageSize
);
return
(
<
div
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
h2
className=
"text-xl font-bold text-gray-800"
>
Quản lý Email nhận thông tin
</
h2
>
<
p
className=
"text-sm text-gray-500 mt-1"
>
Danh sách các địa chỉ email / liên hệ nhận thông tin từ VCCI.
</
p
>
</
div
>
<
Badge
variant=
"outline"
className=
"border-pink-300 text-pink-600 text-sm px-3 py-1"
>
<
Mail
className=
"h-3.5 w-3.5 mr-1"
/>
{
total
}
email
</
Badge
>
</
div
>
<
div
className=
"relative w-72 mb-4"
>
<
Search
size=
{
16
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<
Input
className=
"pl-9"
placeholder=
"Tìm email, tên, tổ chức..."
value=
{
search
}
onChange=
{
(
e
)
=>
{
setSearch
(
e
.
target
.
value
);
setPage
(
1
);
}
}
/>
</
div
>
<
div
className=
"bg-white rounded-lg border shadow-sm overflow-hidden"
>
<
Table
>
<
TableHeader
>
<
TableRow
>
<
TableHead
className=
"w-8"
>
#
</
TableHead
>
<
TableHead
>
Email
</
TableHead
>
<
TableHead
>
Họ tên
</
TableHead
>
<
TableHead
>
Tổ chức
</
TableHead
>
<
TableHead
>
Số điện thoại
</
TableHead
>
<
TableHead
>
Nhu cầu
</
TableHead
>
<
TableHead
>
Ngày gửi
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
isLoading
?
(
Array
.
from
({
length
:
5
}).
map
((
_
,
i
)
=>
(
<
TableRow
key=
{
i
}
>
{
Array
.
from
({
length
:
7
}).
map
((
_
,
j
)
=>
(
<
TableCell
key=
{
j
}
><
Skeleton
className=
"h-4 w-full"
/></
TableCell
>
))
}
</
TableRow
>
))
)
:
rows
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
7
}
className=
"text-center text-gray-400 py-10 text-sm"
>
Chưa có thông tin email nào.
</
TableCell
>
</
TableRow
>
)
:
rows
.
map
((
item
:
any
,
idx
:
number
)
=>
(
<
TableRow
key=
{
item
.
id
??
idx
}
>
<
TableCell
className=
"text-gray-400 text-sm"
>
{
(
page
-
1
)
*
pageSize
+
idx
+
1
}
</
TableCell
>
<
TableCell
>
<
a
href=
{
`mailto:${item.email}`
}
className=
"text-sm font-medium text-blue-600 hover:underline"
>
{
item
.
email
}
</
a
>
</
TableCell
>
<
TableCell
className=
"text-sm"
>
{
item
.
full_name
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-600 max-w-40 truncate"
>
{
item
.
organization_name
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-600"
>
{
item
.
phone
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-500 max-w-[140px] truncate"
>
{
item
.
demand
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-gray-400 text-sm"
>
{
item
.
created_at
?
dayjs
(
item
.
created_at
).
format
(
'DD/MM/YYYY'
)
:
'—'
}
</
TableCell
>
</
TableRow
>
))
}
</
TableBody
>
</
Table
>
</
div
>
{
totalPages
>
1
&&
(
<
div
className=
"flex justify-end gap-2 mt-4"
>
<
Button
variant=
"outline"
size=
"sm"
disabled=
{
page
<=
1
}
onClick=
{
()
=>
setPage
((
p
)
=>
p
-
1
)
}
>
Trước
</
Button
>
<
span
className=
"text-sm text-gray-500 self-center"
>
{
page
}
/
{
totalPages
}
</
span
>
<
Button
variant=
"outline"
size=
"sm"
disabled=
{
page
>=
totalPages
}
onClick=
{
()
=>
setPage
((
p
)
=>
p
+
1
)
}
>
Sau
</
Button
>
</
div
>
)
}
</
div
>
);
}
src/app/admin/header-config/components/header-category-table.tsx
View file @
080821e5
...
@@ -158,7 +158,7 @@ export function HeaderCategoryTable({
...
@@ -158,7 +158,7 @@ export function HeaderCategoryTable({
</
TableRow
>
</
TableRow
>
)
:
(
)
:
(
rows
.
map
((
item
,
index
)
=>
{
rows
.
map
((
item
,
index
)
=>
{
const
hasChildren
=
rows
.
some
((
entry
)
=>
entry
.
parentId
===
item
.
id
)
;
const
hasChildren
=
item
.
children
.
length
>
0
;
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
canCreateChild
=
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
canCreateChild
=
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
canManagePosts
=
item
.
type
===
"page"
||
item
.
type
===
"news"
;
const
canManagePosts
=
item
.
type
===
"page"
||
item
.
type
===
"news"
;
...
...
src/app/admin/members/[id]/page.tsx
0 → 100644
View file @
080821e5
import
{
AdminMemberForm
}
from
"@/components/admin/member-form"
;
interface
AdminMemberDetailPageProps
{
params
:
Promise
<
{
id
:
string
}
>
;
}
export
default
async
function
AdminMemberDetailPage
({
params
}:
AdminMemberDetailPageProps
)
{
const
{
id
}
=
await
params
;
return
<
AdminMemberForm
memberId=
{
id
}
/>;
}
src/app/admin/members/fields/page.tsx
0 → 100644
View file @
080821e5
"use client"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Plus
,
Save
,
Trash2
,
X
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MemberField
,
createMemberFieldId
,
persistMemberFields
,
readMemberFields
,
}
from
"@/mockdata/members"
;
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
FieldFormDialogProps
{
open
:
boolean
;
initial
:
MemberField
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
void
;
}
function
FieldFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
}:
FieldFormDialogProps
)
{
const
[
name
,
setName
]
=
React
.
useState
(
""
);
React
.
useEffect
(()
=>
{
if
(
open
)
setName
(
initial
?.
name
??
""
);
},
[
open
,
initial
]);
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
toast
.
error
(
"Vui lòng nhập tên lĩnh vực"
);
return
;
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
};
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa lĩnh vực"
:
"Thêm lĩnh vực mới"
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Tên lĩnh vực
<
span
className=
"text-red-500"
>
*
</
span
>
</
Label
>
<
Input
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
placeholder=
"Nhập tên lĩnh vực..."
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
/>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
<
Button
type=
"button"
variant=
"outline"
className=
"border-[#063e8e]/15 text-gray-700"
onClick=
{
()
=>
onOpenChange
(
false
)
}
>
<
X
className=
"mr-2 h-4 w-4"
/>
Hủy
</
Button
>
<
Button
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
</
Button
>
</
div
>
</
div
>
</
DialogContent
>
</
Dialog
>
);
}
export
default
function
AdminMemberFieldsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
MemberField
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
React
.
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
React
.
useState
<
MemberField
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
MemberField
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
React
.
useEffect
(()
=>
{
setItems
(
readMemberFields
());
setReady
(
true
);
},
[]);
const
filtered
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
return
items
.
filter
((
item
)
=>
item
.
name
.
toLowerCase
().
includes
(
keyword
));
},
[
items
,
search
]);
const
openCreate
=
()
=>
{
setEditTarget
(
null
);
setDialogOpen
(
true
);
};
const
openEdit
=
(
item
:
MemberField
)
=>
{
setEditTarget
(
item
);
setDialogOpen
(
true
);
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberField
[];
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
));
toast
.
success
(
"Đã cập nhật lĩnh vực"
);
}
else
{
next
=
[...
items
,
{
id
:
createMemberFieldId
(),
name
:
data
.
name
}];
toast
.
success
(
"Đã thêm lĩnh vực mới"
);
}
setItems
(
next
);
persistMemberFields
(
next
);
setDialogOpen
(
false
);
};
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
persistMemberFields
(
next
);
toast
.
success
(
"Đã xóa lĩnh vực"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm lĩnh vực..."
actionLabel=
"Thêm lĩnh vực"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng lĩnh vực:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
onActionClick=
{
openCreate
}
>
<
Table
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
TableHead
className=
"py-4 text-white"
>
Tên lĩnh vực
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
<
div
className=
"h-10 animate-pulse rounded-xl bg-[#063e8e]/10"
/>
</
TableCell
>
</
TableRow
>
))
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
Không có lĩnh vực nào
</
TableCell
>
</
TableRow
>
)
:
(
filtered
.
map
((
item
,
index
)
=>
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-500"
>
{
index
+
1
}
</
TableCell
>
<
TableCell
className=
"py-3 text-sm font-medium text-gray-800"
>
{
item
.
name
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick=
{
()
=>
openEdit
(
item
)
}
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
</
AdminTableLayout
>
<
FieldFormDialog
open=
{
dialogOpen
}
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa lĩnh vực"
description=
{
<>
Bạn có chắc muốn xóa lĩnh vực
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/members/page.tsx
View file @
080821e5
This diff is collapsed.
Click to expand it.
src/app/admin/members/regions/page.tsx
0 → 100644
View file @
080821e5
"use client"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Plus
,
Save
,
Trash2
,
X
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MemberRegion
,
createMemberRegionId
,
persistMemberRegions
,
readMemberRegions
,
}
from
"@/mockdata/members"
;
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
RegionFormDialogProps
{
open
:
boolean
;
initial
:
MemberRegion
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
void
;
}
function
RegionFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
}:
RegionFormDialogProps
)
{
const
[
name
,
setName
]
=
React
.
useState
(
""
);
React
.
useEffect
(()
=>
{
if
(
open
)
setName
(
initial
?.
name
??
""
);
},
[
open
,
initial
]);
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
toast
.
error
(
"Vui lòng nhập tên khu vực"
);
return
;
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
};
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa khu vực"
:
"Thêm khu vực mới"
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Tên khu vực
<
span
className=
"text-red-500"
>
*
</
span
>
</
Label
>
<
Input
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
placeholder=
"Nhập tên khu vực..."
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
/>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
<
Button
type=
"button"
variant=
"outline"
className=
"border-[#063e8e]/15 text-gray-700"
onClick=
{
()
=>
onOpenChange
(
false
)
}
>
<
X
className=
"mr-2 h-4 w-4"
/>
Hủy
</
Button
>
<
Button
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
</
Button
>
</
div
>
</
div
>
</
DialogContent
>
</
Dialog
>
);
}
export
default
function
AdminMemberRegionsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
MemberRegion
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
React
.
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
React
.
useState
<
MemberRegion
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
MemberRegion
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
React
.
useEffect
(()
=>
{
setItems
(
readMemberRegions
());
setReady
(
true
);
},
[]);
const
filtered
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
return
items
.
filter
((
item
)
=>
item
.
name
.
toLowerCase
().
includes
(
keyword
));
},
[
items
,
search
]);
const
openCreate
=
()
=>
{
setEditTarget
(
null
);
setDialogOpen
(
true
);
};
const
openEdit
=
(
item
:
MemberRegion
)
=>
{
setEditTarget
(
item
);
setDialogOpen
(
true
);
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberRegion
[];
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
));
toast
.
success
(
"Đã cập nhật khu vực"
);
}
else
{
next
=
[...
items
,
{
id
:
createMemberRegionId
(),
name
:
data
.
name
}];
toast
.
success
(
"Đã thêm khu vực mới"
);
}
setItems
(
next
);
persistMemberRegions
(
next
);
setDialogOpen
(
false
);
};
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
persistMemberRegions
(
next
);
toast
.
success
(
"Đã xóa khu vực"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm khu vực..."
actionLabel=
"Thêm khu vực"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng khu vực:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
onActionClick=
{
openCreate
}
>
<
Table
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
TableHead
className=
"py-4 text-white"
>
Tên khu vực
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
<
div
className=
"h-10 animate-pulse rounded-xl bg-[#063e8e]/10"
/>
</
TableCell
>
</
TableRow
>
))
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
Không có khu vực nào
</
TableCell
>
</
TableRow
>
)
:
(
filtered
.
map
((
item
,
index
)
=>
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-500"
>
{
index
+
1
}
</
TableCell
>
<
TableCell
className=
"py-3 text-sm font-medium text-gray-800"
>
{
item
.
name
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick=
{
()
=>
openEdit
(
item
)
}
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
</
AdminTableLayout
>
<
RegionFormDialog
open=
{
dialogOpen
}
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa khu vực"
description=
{
<>
Bạn có chắc muốn xóa khu vực
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/partners/page.tsx
deleted
100644 → 0
View file @
8d514056
'use client'
;
import
React
,
{
useState
}
from
'react'
;
import
{
useGetOrganizations
}
from
'@/api/endpoints/organizations'
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
'@/components/ui/table'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Avatar
,
AvatarFallback
,
AvatarImage
}
from
'@/components/ui/avatar'
;
import
{
Search
,
ExternalLink
}
from
'lucide-react'
;
import
{
Skeleton
}
from
'@/components/ui/skeleton'
;
import
dayjs
from
'dayjs'
;
export
default
function
PartnersPage
()
{
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
pageSize
=
20
;
// Filter by type = "PARTNER" or use org categories — using filters param
const
{
data
,
isLoading
}
=
useGetOrganizations
({
currentPage
:
String
(
page
),
pageSize
:
String
(
pageSize
),
filters
:
search
?
`name@=
${
search
}
`
:
undefined
,
// note: filter by partner type when backend supports it
});
const
rows
=
(
data
as
any
)?.
responseData
?.
rows
??
(
data
as
any
)?.
data
?.
rows
??
[];
const
total
=
(
data
as
any
)?.
responseData
?.
count
??
(
data
as
any
)?.
data
?.
count
??
0
;
const
totalPages
=
Math
.
ceil
(
total
/
pageSize
);
return
(
<
div
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
h2
className=
"text-xl font-bold text-gray-800"
>
Quản lý Đối tác
</
h2
>
<
p
className=
"text-sm text-gray-500 mt-1"
>
Danh sách tổ chức / đối tác liên kết với VCCI.
</
p
>
</
div
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/30 text-[#063e8e] text-sm px-3 py-1"
>
{
total
}
đối tác
</
Badge
>
</
div
>
<
div
className=
"relative w-72 mb-4"
>
<
Search
size=
{
16
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<
Input
className=
"pl-9"
placeholder=
"Tìm theo tên đối tác..."
value=
{
search
}
onChange=
{
(
e
)
=>
{
setSearch
(
e
.
target
.
value
);
setPage
(
1
);
}
}
/>
</
div
>
<
div
className=
"bg-white rounded-lg border shadow-sm overflow-hidden"
>
<
Table
>
<
TableHeader
>
<
TableRow
>
<
TableHead
className=
"w-8"
>
#
</
TableHead
>
<
TableHead
>
Tên tổ chức
</
TableHead
>
<
TableHead
>
Mã số thuế
</
TableHead
>
<
TableHead
>
Website
</
TableHead
>
<
TableHead
>
Email
</
TableHead
>
<
TableHead
>
Địa chỉ
</
TableHead
>
<
TableHead
>
Ngày tạo
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
isLoading
?
(
Array
.
from
({
length
:
5
}).
map
((
_
,
i
)
=>
(
<
TableRow
key=
{
i
}
>
{
Array
.
from
({
length
:
7
}).
map
((
_
,
j
)
=>
(
<
TableCell
key=
{
j
}
><
Skeleton
className=
"h-4 w-full"
/></
TableCell
>
))
}
</
TableRow
>
))
)
:
rows
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
7
}
className=
"text-center text-gray-400 py-10 text-sm"
>
Chưa có đối tác nào.
</
TableCell
>
</
TableRow
>
)
:
rows
.
map
((
org
:
any
,
idx
:
number
)
=>
(
<
TableRow
key=
{
org
.
id
??
idx
}
>
<
TableCell
className=
"text-gray-400 text-sm"
>
{
(
page
-
1
)
*
pageSize
+
idx
+
1
}
</
TableCell
>
<
TableCell
>
<
div
className=
"flex items-center gap-3"
>
<
Avatar
className=
"h-8 w-8 shrink-0"
>
<
AvatarImage
src=
{
org
.
avatar
}
alt=
{
org
.
name
}
/>
<
AvatarFallback
className=
"bg-violet-100 text-violet-700 text-xs font-bold"
>
{
org
.
name
?.
slice
(
0
,
2
).
toUpperCase
()
}
</
AvatarFallback
>
</
Avatar
>
<
p
className=
"font-medium text-sm truncate max-w-[200px]"
>
{
org
.
name
}
</
p
>
</
div
>
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-600"
>
{
org
.
tax_code
||
'—'
}
</
TableCell
>
<
TableCell
>
{
org
.
website
?
(
<
a
href=
{
org
.
website
}
target=
"_blank"
rel=
"noreferrer"
className=
"flex items-center gap-1 text-xs text-blue-500 hover:underline"
>
<
ExternalLink
size=
{
12
}
/>
{
org
.
website
.
replace
(
/^https
?
:
\/\/
/
,
''
)
}
</
a
>
)
:
<
span
className=
"text-gray-300 text-sm"
>
—
</
span
>
}
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-600"
>
{
org
.
org_email
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-sm text-gray-500 max-w-[140px] truncate"
>
{
org
.
address
||
'—'
}
</
TableCell
>
<
TableCell
className=
"text-gray-400 text-sm"
>
{
org
.
created_at
?
dayjs
(
org
.
created_at
).
format
(
'DD/MM/YYYY'
)
:
'—'
}
</
TableCell
>
</
TableRow
>
))
}
</
TableBody
>
</
Table
>
</
div
>
{
totalPages
>
1
&&
(
<
div
className=
"flex justify-end gap-2 mt-4"
>
<
Button
variant=
"outline"
size=
"sm"
disabled=
{
page
<=
1
}
onClick=
{
()
=>
setPage
((
p
)
=>
p
-
1
)
}
>
Trước
</
Button
>
<
span
className=
"text-sm text-gray-500 self-center"
>
{
page
}
/
{
totalPages
}
</
span
>
<
Button
variant=
"outline"
size=
"sm"
disabled=
{
page
>=
totalPages
}
onClick=
{
()
=>
setPage
((
p
)
=>
p
+
1
)
}
>
Sau
</
Button
>
</
div
>
)
}
</
div
>
);
}
src/app/admin/videos/page.tsx
0 → 100644
View file @
080821e5
This diff is collapsed.
Click to expand it.
src/app/admin/website-config/page.tsx
deleted
100644 → 0
View file @
8d514056
'use client'
;
import
React
,
{
useEffect
,
useState
}
from
'react'
;
import
{
useGetConfig
,
usePutConfig
,
getGetConfigQueryKey
,
}
from
'@/api/endpoints/website-config'
;
import
{
WebConfig
}
from
'@/api/models/webConfig'
;
import
{
useQueryClient
}
from
'@tanstack/react-query'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Label
}
from
'@/components/ui/label'
;
import
{
Skeleton
}
from
'@/components/ui/skeleton'
;
import
{
Save
,
Globe
,
Phone
,
Mail
,
MapPin
,
Link
as
LinkIcon
}
from
'lucide-react'
;
import
{
Card
,
CardContent
,
CardHeader
,
CardTitle
,
CardDescription
}
from
'@/components/ui/card'
;
import
{
Separator
}
from
'@/components/ui/separator'
;
import
{
toast
}
from
'sonner'
;
export
default
function
WebsiteConfigPage
()
{
const
qc
=
useQueryClient
();
const
{
data
,
isLoading
}
=
useGetConfig
({});
const
config
:
WebConfig
|
undefined
=
(
data
as
any
)?.
responseData
??
(
data
as
any
)?.
data
??
undefined
;
const
[
form
,
setForm
]
=
useState
<
WebConfig
>
({
name
:
''
,
name_en
:
''
,
address
:
''
,
address_en
:
''
,
logo
:
''
,
link
:
''
,
phone
:
''
,
email
:
''
,
social
:
''
,
});
useEffect
(()
=>
{
if
(
config
)
{
setForm
({
name
:
config
.
name
??
''
,
name_en
:
config
.
name_en
??
''
,
address
:
config
.
address
??
''
,
address_en
:
config
.
address_en
??
''
,
logo
:
config
.
logo
??
''
,
link
:
config
.
link
??
''
,
phone
:
config
.
phone
??
''
,
email
:
config
.
email
??
''
,
social
:
config
.
social
??
''
,
});
}
},
[
config
]);
const
{
mutate
:
save
,
isPending
}
=
usePutConfig
({
mutation
:
{
onSuccess
:
()
=>
{
qc
.
invalidateQueries
({
queryKey
:
getGetConfigQueryKey
()
});
toast
.
success
(
'Đã lưu thông tin website!'
);
},
onError
:
()
=>
{
toast
.
error
(
'Có lỗi khi lưu thông tin. Vui lòng thử lại.'
);
},
},
});
const
setField
=
<
K
extends
keyof
WebConfig
>
(key: K, value: WebConfig[K]) =
>
{
setForm
((
prev
)
=>
({
...
prev
,
[
key
]:
value
}));
}
;
const handleSubmit = (e: React.FormEvent) =
>
{
e
.
preventDefault
();
if
(
!
config
?.
id
)
return
;
save
({
params
:
{
filters
:
String
(
config
.
id
)
},
data
:
form
});
}
;
return (
<
div
className=
"max-w-2xl space-y-6"
>
<
div
>
<
h2
className=
"text-xl font-bold text-gray-800"
>
Thông tin website
</
h2
>
<
p
className=
"text-sm text-gray-500 mt-1"
>
Cập nhật thông tin chung hiển thị trên website VCCI News.
</
p
>
</
div
>
{
isLoading
?
(
<
div
className=
"space-y-4"
>
{
Array
.
from
({
length
:
6
}).
map
((
_
,
i
)
=>
(
<
div
key=
{
i
}
className=
"space-y-1.5"
>
<
Skeleton
className=
"h-4 w-24"
/>
<
Skeleton
className=
"h-10 w-full"
/>
</
div
>
))
}
</
div
>
)
:
(
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-6"
>
{
/* Tên website */
}
<
Card
>
<
CardHeader
>
<
CardTitle
className=
"text-base flex items-center gap-2"
>
<
Globe
className=
"h-4 w-4 text-[#063e8e]"
/>
Tên
&
Logo
</
CardTitle
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
<
div
className=
"grid grid-cols-2 gap-4"
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"name"
>
Tên (Tiếng Việt)
</
Label
>
<
Input
id=
"name"
value=
{
form
.
name
}
onChange=
{
(
e
)
=>
setField
(
'name'
,
e
.
target
.
value
)
}
placeholder=
"VCCI HCM"
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"name_en"
>
Tên (Tiếng Anh)
</
Label
>
<
Input
id=
"name_en"
value=
{
form
.
name_en
}
onChange=
{
(
e
)
=>
setField
(
'name_en'
,
e
.
target
.
value
)
}
placeholder=
"VCCI HCM (English)"
/>
</
div
>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"logo"
>
URL Logo
</
Label
>
<
div
className=
"flex gap-3 items-start"
>
<
Input
id=
"logo"
value=
{
form
.
logo
}
onChange=
{
(
e
)
=>
setField
(
'logo'
,
e
.
target
.
value
)
}
placeholder=
"https://..."
className=
"flex-1"
/>
{
form
.
logo
&&
(
<
img
src=
{
form
.
logo
}
alt=
"logo preview"
className=
"h-10 w-auto border rounded object-contain"
onError=
{
(
e
)
=>
{
(
e
.
currentTarget
as
HTMLImageElement
).
style
.
display
=
'none'
;
}
}
/>
)
}
</
div
>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"link"
>
<
LinkIcon
className=
"inline h-3.5 w-3.5 mr-1"
/>
Đường dẫn website
</
Label
>
<
Input
id=
"link"
value=
{
form
.
link
}
onChange=
{
(
e
)
=>
setField
(
'link'
,
e
.
target
.
value
)
}
placeholder=
"https://vccihn.com"
/>
</
div
>
</
CardContent
>
</
Card
>
<
Separator
/>
{
/* Liên hệ */
}
<
Card
>
<
CardHeader
>
<
CardTitle
className=
"text-base flex items-center gap-2"
>
<
Phone
className=
"h-4 w-4 text-[#063e8e]"
/>
Thông tin liên hệ
</
CardTitle
>
<
CardDescription
className=
"text-xs"
>
Hiển thị ở footer và trang liên hệ.
</
CardDescription
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
<
div
className=
"grid grid-cols-2 gap-4"
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"phone"
>
<
Phone
className=
"inline h-3.5 w-3.5 mr-1"
/>
Điện thoại
</
Label
>
<
Input
id=
"phone"
value=
{
form
.
phone
}
onChange=
{
(
e
)
=>
setField
(
'phone'
,
e
.
target
.
value
)
}
placeholder=
"028 xxxx xxxx"
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"email"
>
<
Mail
className=
"inline h-3.5 w-3.5 mr-1"
/>
Email
</
Label
>
<
Input
id=
"email"
type=
"email"
value=
{
form
.
email
}
onChange=
{
(
e
)
=>
setField
(
'email'
,
e
.
target
.
value
)
}
placeholder=
"info@vcci.com.vn"
/>
</
div
>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"address"
>
<
MapPin
className=
"inline h-3.5 w-3.5 mr-1"
/>
Địa chỉ (Tiếng Việt)
</
Label
>
<
Input
id=
"address"
value=
{
form
.
address
}
onChange=
{
(
e
)
=>
setField
(
'address'
,
e
.
target
.
value
)
}
placeholder=
"171 Võ Thị Sáu, Quận 3, TP.HCM"
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"address_en"
>
<
MapPin
className=
"inline h-3.5 w-3.5 mr-1"
/>
Địa chỉ (Tiếng Anh)
</
Label
>
<
Input
id=
"address_en"
value=
{
form
.
address_en
}
onChange=
{
(
e
)
=>
setField
(
'address_en'
,
e
.
target
.
value
)
}
placeholder=
"171 Vo Thi Sau, District 3, HCMC"
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
htmlFor=
"social"
>
Mạng xã hội (URL Facebook / Fanpage)
</
Label
>
<
Input
id=
"social"
value=
{
form
.
social
}
onChange=
{
(
e
)
=>
setField
(
'social'
,
e
.
target
.
value
)
}
placeholder=
"https://facebook.com/vccihn"
/>
</
div
>
</
CardContent
>
</
Card
>
<
div
className=
"flex justify-end"
>
<
Button
type=
"submit"
disabled=
{
isPending
||
!
config
?.
id
}
className=
"bg-[#063e8e] hover:bg-[#063e8e]/90"
>
<
Save
size=
{
15
}
className=
"mr-2"
/>
{
isPending
?
'Đang lưu...'
:
'Lưu thay đổi'
}
</
Button
>
</
div
>
</
form
>
)
}
</
div
>
);
}
src/components/admin/admin-table-layout.tsx
View file @
080821e5
...
@@ -10,6 +10,7 @@ interface AdminTableLayoutProps {
...
@@ -10,6 +10,7 @@ interface AdminTableLayoutProps {
searchPlaceholder
?:
string
;
searchPlaceholder
?:
string
;
actionLabel
?:
string
;
actionLabel
?:
string
;
actionIcon
?:
React
.
ReactNode
;
actionIcon
?:
React
.
ReactNode
;
actionMeta
?:
React
.
ReactNode
;
actionDisabled
?:
boolean
;
actionDisabled
?:
boolean
;
children
:
React
.
ReactNode
;
children
:
React
.
ReactNode
;
filters
?:
React
.
ReactNode
;
filters
?:
React
.
ReactNode
;
...
@@ -22,6 +23,7 @@ export function AdminTableLayout({
...
@@ -22,6 +23,7 @@ export function AdminTableLayout({
searchPlaceholder
=
"Tìm kiếm..."
,
searchPlaceholder
=
"Tìm kiếm..."
,
actionLabel
,
actionLabel
,
actionIcon
,
actionIcon
,
actionMeta
,
actionDisabled
=
false
,
actionDisabled
=
false
,
children
,
children
,
filters
,
filters
,
...
@@ -41,6 +43,9 @@ export function AdminTableLayout({
...
@@ -41,6 +43,9 @@ export function AdminTableLayout({
{
filters
}
{
filters
}
</
div
>
</
div
>
{
actionLabel
||
actionMeta
?
(
<
div
className=
"flex items-center gap-3 self-start sm:self-auto"
>
{
actionMeta
}
{
actionLabel
?
(
{
actionLabel
?
(
<
Button
<
Button
type=
"button"
type=
"button"
...
@@ -53,8 +58,10 @@ export function AdminTableLayout({
...
@@ -53,8 +58,10 @@ export function AdminTableLayout({
</
Button
>
</
Button
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
)
:
null
}
</
div
>
<
div
className=
"overflow-hidden rounded-xl border border-[#063e8e]/
15 bg-white shadow-sm
"
>
<
div
className=
"overflow-hidden rounded-xl border border-[#063e8e]/
20 bg-white shadow-sm [&_tbody_td:not(:last-child)]:border-r [&_tbody_td:not(:last-child)]:border-[#063e8e]/20 [&_thead_th:not(:last-child)]:border-r [&_thead_th:not(:last-child)]:border-white/15
"
>
{
children
}
{
children
}
</
div
>
</
div
>
</
div
>
</
div
>
...
...
src/components/admin/member-form.tsx
0 → 100644
View file @
080821e5
This diff is collapsed.
Click to expand it.
src/components/admin/rich-text-editor.tsx
View file @
080821e5
...
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
...
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import
{
useMemo
,
useRef
}
from
"react"
;
import
{
useMemo
,
useRef
}
from
"react"
;
import
type
{
JoditEditorProps
}
from
"jodit-react"
;
import
type
{
JoditEditorProps
}
from
"jodit-react"
;
const
JoditEditor
=
dynamic
(()
=>
import
(
"jodit-react"
),
{
const
JoditEditor
=
dynamic
(()
=>
import
(
"jodit-react"
)
.
then
((
mod
)
=>
mod
.
default
)
,
{
ssr
:
false
,
ssr
:
false
,
loading
:
()
=>
(
loading
:
()
=>
(
<
div
className=
"flex min-h-[260px] items-center justify-center rounded-2xl border border-[#063e8e]/15 bg-white text-sm text-gray-500"
>
<
div
className=
"flex min-h-[260px] items-center justify-center rounded-2xl border border-[#063e8e]/15 bg-white text-sm text-gray-500"
>
...
...
src/components/shared/admin-header.tsx
View file @
080821e5
...
@@ -2,14 +2,15 @@
...
@@ -2,14 +2,15 @@
import
React
from
'react'
;
import
React
from
'react'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
Menu
}
from
'lucide-react'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
Menu
}
from
'lucide-react'
;
const
routeLabels
:
Record
<
string
,
string
>
=
{
const
routeLabels
:
Record
<
string
,
string
>
=
{
'/admin/dashboard'
:
'Dashboard'
,
'/admin/dashboard'
:
'Dashboard'
,
'/admin/header-config'
:
'Cấu hình Danh mục'
,
'/admin/header-config'
:
'Cấu hình Danh mục'
,
'/admin/news'
:
'Quản lý bài viết'
,
'/admin/news'
:
'Quản lý bài viết'
,
'/admin/videos'
:
'Quản lý video'
,
'/admin/members'
:
'Quản lý Hội viên'
,
'/admin/members'
:
'Quản lý Hội viên'
,
'/admin/partners'
:
'Quản lý Đối tác'
,
'/admin/partners'
:
'Quản lý Đối tác'
,
'/admin/emails'
:
'Email nhận thông tin'
,
'/admin/emails'
:
'Email nhận thông tin'
,
...
@@ -20,7 +21,7 @@ function getTitle(pathname: string): string {
...
@@ -20,7 +21,7 @@ function getTitle(pathname: string): string {
if
(
routeLabels
[
pathname
])
return
routeLabels
[
pathname
];
if
(
routeLabels
[
pathname
])
return
routeLabels
[
pathname
];
for
(
const
[
prefix
,
label
]
of
Object
.
entries
(
routeLabels
))
{
for
(
const
[
prefix
,
label
]
of
Object
.
entries
(
routeLabels
))
{
if
(
pathname
.
startsWith
(
prefix
+
'/'
))
return
label
;
if
(
pathname
.
startsWith
(
`
${
prefix
}
/`
))
return
label
;
}
}
return
'Quản trị'
;
return
'Quản trị'
;
...
...
src/components/shared/admin-sidebar.tsx
View file @
080821e5
...
@@ -5,15 +5,12 @@ import Image from 'next/image';
...
@@ -5,15 +5,12 @@ import Image from 'next/image';
import
Link
from
'next/link'
;
import
Link
from
'next/link'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
import
{
BarChart3
,
Building2
,
ChevronDown
,
ChevronDown
,
Globe
,
Globe
,
Layers
,
Layers
,
Mail
,
Newspaper
,
Newspaper
,
Settings
,
Users
,
Users
,
Video
,
}
from
'lucide-react'
;
}
from
'lucide-react'
;
import
logo
from
'@/assets/VCCI-HCM-logo-VN-2025.png'
;
import
logo
from
'@/assets/VCCI-HCM-logo-VN-2025.png'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
...
@@ -28,27 +25,43 @@ type NavItem = {
...
@@ -28,27 +25,43 @@ type NavItem = {
};
};
const
navigation
:
NavItem
[]
=
[
const
navigation
:
NavItem
[]
=
[
// { name: 'Dashboard', href: '/admin/dashboard', icon: BarChart3 },
{
name
:
'Cấu hình danh mục'
,
href
:
'/admin/header-config'
,
icon
:
Layers
},
{
name
:
'Cấu hình danh mục'
,
href
:
'/admin/header-config'
,
icon
:
Layers
},
{
name
:
'Quản lý bài viết'
,
href
:
'/admin/news'
,
icon
:
Newspaper
},
{
name
:
'Quản lý bài viết'
,
href
:
'/admin/news'
,
icon
:
Newspaper
},
// { name: 'Quản lý hội viên', href: '/admin/members', icon: Users },
{
name
:
'Quản lý video'
,
href
:
'/admin/videos'
,
icon
:
Video
},
// { name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 },
{
// { name: 'Email thông tin', href: '/admin/emails', icon: Mail },
name
:
'Quản lý hội viên'
,
// {
icon
:
Users
,
// name: 'Thiết lập',
children
:
[
// icon: Settings,
{
name
:
'Danh sách hội viên'
,
href
:
'/admin/members'
},
// children: [{ name: 'Thông tin website', href: '/admin/website-config' }],
{
name
:
'Quản lý lĩnh vực'
,
href
:
'/admin/members/fields'
},
// },
{
name
:
'Quản lý khu vực'
,
href
:
'/admin/members/regions'
},
],
},
];
];
const
membersReservedSegments
=
new
Set
([
'fields'
,
'regions'
]);
export
function
AdminSidebar
()
{
export
function
AdminSidebar
()
{
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
{
isOpen
}
=
useSidebarStore
();
const
{
isOpen
}
=
useSidebarStore
();
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>>
({
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>>
({
'
Thiết lập
'
:
true
,
'
Quản lý hội viên
'
:
true
,
});
});
const
isItemActive
=
(
href
:
string
)
=>
pathname
===
href
||
pathname
.
startsWith
(
`
${
href
}
/`
);
const
isItemActive
=
React
.
useCallback
(
(
href
:
string
)
=>
{
if
(
href
===
'/admin/members'
)
{
if
(
pathname
===
href
)
return
true
;
if
(
!
pathname
.
startsWith
(
`
${
href
}
/`
))
return
false
;
const
nextSegment
=
pathname
.
slice
(
`
${
href
}
/`
.
length
).
split
(
'/'
)[
0
];
return
Boolean
(
nextSegment
)
&&
!
membersReservedSegments
.
has
(
nextSegment
);
}
return
pathname
===
href
||
pathname
.
startsWith
(
`
${
href
}
/`
);
},
[
pathname
],
);
const
isGroupActive
=
(
children
:
NavChild
[])
=>
children
.
some
((
child
)
=>
isItemActive
(
child
.
href
));
const
isGroupActive
=
(
children
:
NavChild
[])
=>
children
.
some
((
child
)
=>
isItemActive
(
child
.
href
));
...
...
src/mockdata/members.ts
0 → 100644
View file @
080821e5
"use client"
;
// ---------------------------------------------------------------------------
// Storage keys
// ---------------------------------------------------------------------------
export
const
MEMBER_STORAGE_KEY
=
"vcci-news.admin-members.data.v1"
;
export
const
MEMBER_FIELD_STORAGE_KEY
=
"vcci-news.admin-member-fields.data.v1"
;
export
const
MEMBER_REGION_STORAGE_KEY
=
"vcci-news.admin-member-regions.data.v1"
;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export
interface
MemberField
{
id
:
string
;
name
:
string
;
}
export
interface
MemberRegion
{
id
:
string
;
name
:
string
;
}
export
interface
MemberImageRef
{
id
:
string
;
name
:
string
;
alt
:
string
;
url
:
string
;
}
import
type
{
AdminNewsContentSection
}
from
"@/mockdata/admin-news"
;
export
interface
MemberItem
{
id
:
string
;
name
:
string
;
is_featured
:
boolean
;
image
:
MemberImageRef
|
null
;
region_id
:
string
;
field_id
:
string
;
address
:
string
;
phone
:
string
;
fax
:
string
;
email
:
string
;
website
:
string
;
introduction
:
AdminNewsContentSection
[];
created_at
:
string
;
updated_at
:
string
;
}
export
interface
MemberFormValues
{
id
?:
string
;
name
:
string
;
is_featured
:
boolean
;
image
:
MemberImageRef
|
null
;
region_id
:
string
;
field_id
:
string
;
address
:
string
;
phone
:
string
;
fax
:
string
;
email
:
string
;
website
:
string
;
introduction
:
AdminNewsContentSection
[];
}
// ---------------------------------------------------------------------------
// ID generators
// ---------------------------------------------------------------------------
export
function
createMemberId
():
string
{
return
`member-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
7
)}
`
;
}
export
function
createMemberFieldId
():
string
{
return
`field-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
7
)}
`
;
}
export
function
createMemberRegionId
():
string
{
return
`region-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
7
)}
`
;
}
// ---------------------------------------------------------------------------
// Seed data
// ---------------------------------------------------------------------------
const
SEED_FIELDS
:
MemberField
[]
=
[
{
id
:
"field-1"
,
name
:
"Công nghiệp"
},
{
id
:
"field-2"
,
name
:
"Thương mại"
},
{
id
:
"field-3"
,
name
:
"Dịch vụ"
},
{
id
:
"field-4"
,
name
:
"Nông nghiệp"
},
{
id
:
"field-5"
,
name
:
"Công nghệ thông tin"
},
];
const
SEED_REGIONS
:
MemberRegion
[]
=
[
{
id
:
"region-1"
,
name
:
"TP. Hồ Chí Minh"
},
{
id
:
"region-2"
,
name
:
"Hà Nội"
},
{
id
:
"region-3"
,
name
:
"Đà Nẵng"
},
{
id
:
"region-4"
,
name
:
"Cần Thơ"
},
{
id
:
"region-5"
,
name
:
"Bình Dương"
},
];
const
SEED_MEMBERS
:
MemberItem
[]
=
[
{
id
:
"member-1"
,
name
:
"Công ty TNHH ABC"
,
is_featured
:
true
,
image
:
null
,
region_id
:
"region-1"
,
field_id
:
"field-2"
,
address
:
"123 Nguyễn Văn Linh, Quận 7, TP. HCM"
,
phone
:
"028 1234 5678"
,
fax
:
"028 1234 5679"
,
email
:
"contact@abc.vn"
,
website
:
"https://abc.vn"
,
introduction
:
[],
created_at
:
new
Date
().
toISOString
(),
updated_at
:
new
Date
().
toISOString
(),
},
];
// ---------------------------------------------------------------------------
// Field helpers
// ---------------------------------------------------------------------------
export
function
getMemberFieldSeed
():
MemberField
[]
{
return
SEED_FIELDS
;
}
export
function
readMemberFields
():
MemberField
[]
{
if
(
typeof
window
===
"undefined"
)
return
getMemberFieldSeed
();
const
raw
=
window
.
localStorage
.
getItem
(
MEMBER_FIELD_STORAGE_KEY
);
if
(
!
raw
)
return
getMemberFieldSeed
();
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
MemberField
[];
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
:
getMemberFieldSeed
();
}
catch
{
return
getMemberFieldSeed
();
}
}
export
function
persistMemberFields
(
fields
:
MemberField
[]):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
MEMBER_FIELD_STORAGE_KEY
,
JSON
.
stringify
(
fields
));
}
// ---------------------------------------------------------------------------
// Region helpers
// ---------------------------------------------------------------------------
export
function
getMemberRegionSeed
():
MemberRegion
[]
{
return
SEED_REGIONS
;
}
export
function
readMemberRegions
():
MemberRegion
[]
{
if
(
typeof
window
===
"undefined"
)
return
getMemberRegionSeed
();
const
raw
=
window
.
localStorage
.
getItem
(
MEMBER_REGION_STORAGE_KEY
);
if
(
!
raw
)
return
getMemberRegionSeed
();
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
MemberRegion
[];
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
:
getMemberRegionSeed
();
}
catch
{
return
getMemberRegionSeed
();
}
}
export
function
persistMemberRegions
(
regions
:
MemberRegion
[]):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
MEMBER_REGION_STORAGE_KEY
,
JSON
.
stringify
(
regions
));
}
// ---------------------------------------------------------------------------
// Member helpers
// ---------------------------------------------------------------------------
export
function
getMemberSeed
():
MemberItem
[]
{
return
SEED_MEMBERS
;
}
export
function
readMembers
():
MemberItem
[]
{
if
(
typeof
window
===
"undefined"
)
return
getMemberSeed
();
const
raw
=
window
.
localStorage
.
getItem
(
MEMBER_STORAGE_KEY
);
if
(
!
raw
)
return
getMemberSeed
();
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
MemberItem
[];
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
:
getMemberSeed
();
}
catch
{
return
getMemberSeed
();
}
}
export
function
persistMembers
(
members
:
MemberItem
[]):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
MEMBER_STORAGE_KEY
,
JSON
.
stringify
(
members
));
}
export
function
cloneMemberFormValues
(
item
:
MemberItem
):
MemberFormValues
{
return
{
id
:
item
.
id
,
name
:
item
.
name
,
is_featured
:
Boolean
(
item
.
is_featured
),
image
:
item
.
image
?
{
...
item
.
image
}
:
null
,
region_id
:
item
.
region_id
,
field_id
:
item
.
field_id
,
address
:
item
.
address
,
phone
:
item
.
phone
,
fax
:
item
.
fax
,
email
:
item
.
email
,
website
:
item
.
website
,
introduction
:
item
.
introduction
.
map
((
section
)
=>
({
...
section
,
images
:
section
.
images
.
map
((
img
)
=>
({
...
img
})),
})),
};
}
export
const
EMPTY_MEMBER_FORM
:
MemberFormValues
=
{
name
:
""
,
is_featured
:
false
,
image
:
null
,
region_id
:
""
,
field_id
:
""
,
address
:
""
,
phone
:
""
,
fax
:
""
,
email
:
""
,
website
:
""
,
introduction
:
[],
};
src/mockdata/videos.ts
0 → 100644
View file @
080821e5
"use client"
;
export
const
VIDEO_STORAGE_KEY
=
"vcci-news.admin-videos.data.v1"
;
export
interface
VideoItem
{
id
:
string
;
name
:
string
;
url
:
string
;
}
export
interface
VideoFormValues
{
id
?:
string
;
name
:
string
;
url
:
string
;
}
const
VIDEO_SEED
:
VideoItem
[]
=
[
{
id
:
"video-1"
,
name
:
"Giới thiệu VCCI News"
,
url
:
"https://www.youtube.com/watch?v=example001"
,
},
{
id
:
"video-2"
,
name
:
"Bản tin hoạt động hội viên"
,
url
:
"https://www.youtube.com/watch?v=example002"
,
},
];
export
function
createVideoId
():
string
{
return
`video-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
7
)}
`
;
}
export
function
getVideoSeed
():
VideoItem
[]
{
return
VIDEO_SEED
;
}
export
function
readVideos
():
VideoItem
[]
{
if
(
typeof
window
===
"undefined"
)
return
getVideoSeed
();
const
raw
=
window
.
localStorage
.
getItem
(
VIDEO_STORAGE_KEY
);
if
(
!
raw
)
return
getVideoSeed
();
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
VideoItem
[];
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
:
getVideoSeed
();
}
catch
{
return
getVideoSeed
();
}
}
export
function
persistVideos
(
items
:
VideoItem
[]):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
VIDEO_STORAGE_KEY
,
JSON
.
stringify
(
items
));
}
export
const
EMPTY_VIDEO_FORM
:
VideoFormValues
=
{
name
:
""
,
url
:
""
,
};
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