Commit 8d514056 authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

fix

parent cff4d2fb
...@@ -116,6 +116,9 @@ importers: ...@@ -116,6 +116,9 @@ importers:
html-react-parser: html-react-parser:
specifier: ^5.2.7 specifier: ^5.2.7
version: 5.2.7(@types/react@19.2.2)(react@19.2.0) version: 5.2.7(@types/react@19.2.2)(react@19.2.0)
jodit-react:
specifier: ^5.3.21
version: 5.3.21(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
...@@ -595,78 +598,92 @@ packages: ...@@ -595,78 +598,92 @@ packages:
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.3': '@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.3': '@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.3': '@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.3': '@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3': '@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.3': '@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.4': '@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.4': '@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.4': '@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.4': '@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.4': '@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.4': '@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.4': '@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.4': '@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
...@@ -754,24 +771,28 @@ packages: ...@@ -754,24 +771,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.0.10': '@next/swc-linux-arm64-musl@16.0.10':
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.0.10': '@next/swc-linux-x64-gnu@16.0.10':
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.0.10': '@next/swc-linux-x64-musl@16.0.10':
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.0.10': '@next/swc-win32-arm64-msvc@16.0.10':
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
...@@ -1585,24 +1606,28 @@ packages: ...@@ -1585,24 +1606,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16': '@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16': '@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.16': '@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.16': '@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
...@@ -1795,41 +1820,49 @@ packages: ...@@ -1795,41 +1820,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1': '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1': '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1': '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1': '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1': '@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1': '@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
...@@ -2781,6 +2814,15 @@ packages: ...@@ -2781,6 +2814,15 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jodit-react@5.3.21:
resolution: {integrity: sha512-dSFVKkrDVbhVwKDjuFMJ3HhPdqeEz/Yz5MhJf9v9B3Gg29CnelKCZ00h6MxXzlhglF3qvtvUTc2HSKrSB15khw==}
peerDependencies:
react: ~0.14 || ^15 || ^16 || ^17 || ^18 || ^19
react-dom: ~0.14 || ^15 || ^16 || ^17 || ^18 || ^19
jodit@4.12.2:
resolution: {integrity: sha512-SoZAH2YvL8JxPmL4muQJPbbF27rFKVzFQOiCRabjtSQcLVghm+XpIm5t9dXq+fCA4d1Z2O+8x/sORPVdLI4zbg==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
...@@ -2893,24 +2935,28 @@ packages: ...@@ -2893,24 +2935,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
...@@ -6772,6 +6818,14 @@ snapshots: ...@@ -6772,6 +6818,14 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jodit-react@5.3.21(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
jodit: 4.12.2
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
jodit@4.12.2: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.0: js-yaml@4.1.0:
......
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { AdminNewsForm } from '@/components/admin/news-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { import {
buildHeaderCategoryTree,
getHeaderCategorySeed, getHeaderCategorySeed,
HEADER_CONFIG_STORAGE_KEY, HEADER_CONFIG_STORAGE_KEY,
HeaderCategoryItem, type HeaderCategoryItem,
normalizeHeaderCategories, normalizeHeaderCategories,
toSlug,
} from '@/mockdata/header-config'; } from '@/mockdata/header-config';
import {
EMPTY_HEADER_CATEGORY_POST_FORM,
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostFormValues,
HeaderCategoryPostItem,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import { ArrowLeft, Save } from 'lucide-react';
const fieldClassName =
'border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30';
function getInitialHeaderConfig() { function readHeaderConfig() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return getHeaderCategorySeed(); return getHeaderCategorySeed();
} }
...@@ -50,281 +30,28 @@ function getInitialHeaderConfig() { ...@@ -50,281 +30,28 @@ function getInitialHeaderConfig() {
} }
} }
function useHeaderConfigModule() {
const [items, setItems] = React.useState<HeaderCategoryItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderConfig());
setIsReady(true);
}, []);
const tree = React.useMemo(() => buildHeaderCategoryTree(items), [items]);
return {
tree,
isReady,
};
}
function getInitialHeaderCategoryPosts() {
if (typeof window === 'undefined') {
return getHeaderCategoryPostSeed();
}
const raw = window.localStorage.getItem(HEADER_CATEGORY_POSTS_STORAGE_KEY);
if (!raw) return getHeaderCategoryPostSeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryPostItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategoryPostSeed();
}
return normalizeHeaderCategoryPosts(parsed);
} catch {
return getHeaderCategoryPostSeed();
}
}
function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
HEADER_CATEGORY_POSTS_STORAGE_KEY,
JSON.stringify(normalizeHeaderCategoryPosts(items)),
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderCategoryPosts());
setIsReady(true);
}, []);
React.useEffect(() => {
if (!isReady) return;
persistHeaderCategoryPosts(items);
}, [isReady, items]);
const getPostsByCategory = React.useCallback(
(categoryId: string) => items.filter((item) => item.category_id === categoryId),
[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: `header-post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
category_id: categoryId,
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 || now.slice(0, 10),
is_active: values.is_active,
created_at: now,
updated_at: now,
};
setItems((current) => normalizeHeaderCategoryPosts([...current, nextPost]));
return nextPost;
},
[],
);
const updatePost = React.useCallback((postId: string, values: HeaderCategoryPostFormValues) => {
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(
(post?: HeaderCategoryPostItem | null): HeaderCategoryPostFormValues => {
if (!post) return EMPTY_HEADER_CATEGORY_POST_FORM;
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: post.content,
thumbnail: post.thumbnail,
published_at: post.published_at,
is_active: post.is_active,
};
},
[],
);
return {
isReady,
getPostsByCategory,
getPostById,
createPost,
updatePost,
toFormValues,
};
}
export default function HeaderCategoryPostFormPage() { export default function HeaderCategoryPostFormPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const categoryId = String(params.categoryId ?? ''); const categoryId = String(params.categoryId ?? '');
const postId = String(params.postId ?? ''); const postId = String(params.postId ?? '');
const isCreate = postId === 'new'; const [category, setCategory] = React.useState<HeaderCategoryItem | null>(null);
const [ready, setReady] = React.useState(false);
const { tree, isReady: categoriesReady } = useHeaderConfigModule();
const {
isReady: postsReady,
getPostsByCategory,
getPostById,
createPost,
updatePost,
toFormValues,
} = useHeaderCategoryPostsModule();
const [form, setForm] = React.useState<HeaderCategoryPostFormValues>(
EMPTY_HEADER_CATEGORY_POST_FORM,
);
const flatCategories = React.useMemo(() => {
const rows: typeof tree = [];
const walk = (items: typeof tree) => {
items.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
walk(tree);
return rows;
}, [tree]);
const category = React.useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories],
);
const post = React.useMemo(() => {
if (isCreate) return null;
return getPostById(postId);
}, [getPostById, isCreate, postId]);
const categoryPosts = React.useMemo(
() => getPostsByCategory(categoryId),
[categoryId, getPostsByCategory],
);
const isSinglePostCategory = category?.type === 'page';
const canManagePosts = Boolean(
category && (category.type === 'page' || category.type === 'news' || category.type === 'image'),
);
React.useEffect(() => { React.useEffect(() => {
if (!postsReady) return; const items = readHeaderConfig();
if (isCreate) { setCategory(items.find((item) => item.id === categoryId) ?? null);
setForm(EMPTY_HEADER_CATEGORY_POST_FORM); setReady(true);
return; }, [categoryId]);
}
setForm(toFormValues(post));
}, [isCreate, post, postsReady, toFormValues]);
React.useEffect(() => { React.useEffect(() => {
if (!categoriesReady || !postsReady) return; if (!ready) return;
if (!category || (category.type !== 'page' && category.type !== 'news')) {
if (!category || !canManagePosts) {
router.replace('/admin/header-config'); router.replace('/admin/header-config');
return;
}
if (isCreate && isSinglePostCategory && categoryPosts.length >= 1) {
toast.error('Danh mục bài viết trang chỉ được tạo 1 bài viết');
router.replace(`/admin/header-config/${categoryId}/posts`);
return;
}
if (!isCreate && !post) {
router.replace(`/admin/header-config/${categoryId}/posts`);
} }
}, [ }, [category, ready, router]);
canManagePosts,
categoriesReady,
category,
categoryId,
categoryPosts.length,
isCreate,
isSinglePostCategory,
post,
postsReady,
router,
]);
const setField = <K extends keyof HeaderCategoryPostFormValues>(
key: K,
value: HeaderCategoryPostFormValues[K],
) => {
setForm((previous) => ({ ...previous, [key]: value }));
};
const handleTitleChange = (value: string) => {
setForm((previous) => ({
...previous,
title: value,
slug: toSlug(value) || previous.slug,
}));
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.title.trim()) { if (!ready || !category || (category.type !== 'page' && category.type !== 'news')) {
toast.error('Tiêu đề bài viết là bắt buộc');
return;
}
if (isCreate) {
createPost(categoryId, form);
toast.success('Đã tạo bài viết');
} else {
updatePost(postId, form);
toast.success('Đã cập nhật bài viết');
}
router.push(`/admin/header-config/${categoryId}/posts`);
};
if (!categoriesReady || !postsReady || !category || !canManagePosts || (!isCreate && !post)) {
return ( return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm"> <div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm">
Đang tải form bài viết... Đang tải form bài viết...
...@@ -333,132 +60,11 @@ export default function HeaderCategoryPostFormPage() { ...@@ -333,132 +60,11 @@ export default function HeaderCategoryPostFormPage() {
} }
return ( return (
<div className="mx-auto max-w-5xl space-y-6"> <AdminNewsForm
<div className="flex flex-col gap-4 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"> newsId={postId}
<Button presetHeaderCategoryId={category.id}
variant="ghost" lockedType={category.type === 'page' ? 'baiviettrang' : 'tintuc'}
asChild returnPath={`/admin/header-config/${category.id}/posts`}
className="h-9 w-fit px-3 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại danh sách bài viết
</Link>
</Button>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-[#063e8e]">
{isCreate ? 'Thêm bài viết mới' : 'Chỉnh sửa bài viết'}
</h2>
<p className="text-sm text-gray-700">
Danh mục hiện tại: <span className="font-medium text-black">{category.name}</span>
</p>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Tiêu đề bài viết *</Label>
<Input
required
value={form.title}
onChange={(event) => handleTitleChange(event.target.value)}
placeholder="Nhập tiêu đề bài viết"
className={fieldClassName}
/> />
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Slug</Label>
<Input
value={form.slug}
onChange={(event) => setField('slug', event.target.value)}
placeholder="tieu-de-bai-viet"
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày đăng</Label>
<Input
type="date"
value={form.published_at}
onChange={(event) => setField('published_at', event.target.value)}
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Ảnh đại diện</Label>
<Input
value={form.thumbnail}
onChange={(event) => setField('thumbnail', event.target.value)}
placeholder="https://..."
className={fieldClassName}
/>
{form.thumbnail ? (
<div className="mt-3 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={form.thumbnail}
alt={form.title || 'thumbnail'}
className="h-48 w-full rounded-lg object-cover"
/>
</div>
) : null}
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Tóm tắt</Label>
<Textarea
rows={4}
value={form.excerpt}
onChange={(event) => setField('excerpt', event.target.value)}
placeholder="Nhập mô tả ngắn cho bài viết"
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Nội dung</Label>
<Textarea
rows={12}
value={form.content}
onChange={(event) => setField('content', event.target.value)}
placeholder="<p>Nội dung bài viết...</p>"
className={`${fieldClassName} font-mono text-sm`}
/>
</div>
</div>
<div className="flex items-center gap-3 rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 px-4 py-3">
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setField('is_active', checked)}
className="data-[state=checked]:bg-[#063e8e] data-[state=unchecked]:bg-gray-300"
/>
<Label className="cursor-pointer text-sm text-gray-700">Hiển thị bài viết</Label>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts`}>Hủy</Link>
</Button>
<Button type="submit" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90">
<Save className="mr-2 h-4 w-4" />
{isCreate ? 'Lưu bài viết' : 'Cập nhật bài viết'}
</Button>
</div>
</form>
</div>
); );
} }
'use client'; 'use client';
import React from 'react'; import React from 'react';
import dayjs from 'dayjs';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import dayjs from 'dayjs';
import { import {
AlertDialog, ArrowLeft,
AlertDialogAction, Edit,
AlertDialogCancel, EyeOff,
AlertDialogContent, FileText,
AlertDialogDescription, MoreHorizontal,
AlertDialogFooter, Plus,
AlertDialogHeader, Star,
AlertDialogTitle, Trash2,
} from '@/components/ui/alert-dialog'; } from 'lucide-react';
import { AdminDeleteDialog } from '@/components/admin/admin-delete-dialog';
import { AdminStatsGrid } from '@/components/admin/admin-stats-grid';
import { AdminTableLayout } from '@/components/admin/admin-table-layout';
import { SafeNextImage } from '@/components/admin/safe-next-image';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Table, Table,
TableBody, TableBody,
...@@ -26,32 +36,21 @@ import { ...@@ -26,32 +36,21 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import {
ADMIN_NEWS_TYPE_LABELS,
type AdminNewsItem,
persistAdminNewsItems,
readAdminNewsItems,
} from '@/mockdata/admin-news';
import { import {
buildHeaderCategoryTree, buildHeaderCategoryTree,
getHeaderCategorySeed, getHeaderCategorySeed,
HEADER_CONFIG_STORAGE_KEY, HEADER_CONFIG_STORAGE_KEY,
HeaderCategoryItem, type HeaderCategoryItem,
HeaderCategoryType,
normalizeHeaderCategories, normalizeHeaderCategories,
} from '@/mockdata/header-config'; } from '@/mockdata/header-config';
import {
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostItem,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import {
ArrowLeft,
Eye,
EyeOff,
Image as ImageIcon,
Pencil,
Plus,
Search,
Trash2,
} from 'lucide-react';
function getInitialHeaderConfig() { function readHeaderConfig() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return getHeaderCategorySeed(); return getHeaderCategorySeed();
} }
...@@ -71,157 +70,139 @@ function getInitialHeaderConfig() { ...@@ -71,157 +70,139 @@ function getInitialHeaderConfig() {
} }
} }
function useHeaderConfigModule() { function formatDateTime(value: string) {
const [items, setItems] = React.useState<HeaderCategoryItem[]>([]); return value ? dayjs(value).format('DD/MM/YYYY HH:mm') : '—';
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderConfig());
setIsReady(true);
}, []);
const tree = React.useMemo(() => buildHeaderCategoryTree(items), [items]);
return {
tree,
isReady,
};
} }
function getInitialHeaderCategoryPosts() { function flattenTree(items: ReturnType<typeof buildHeaderCategoryTree>) {
if (typeof window === 'undefined') { const rows: ReturnType<typeof buildHeaderCategoryTree> = [];
return getHeaderCategoryPostSeed();
}
const raw = window.localStorage.getItem(HEADER_CATEGORY_POSTS_STORAGE_KEY);
if (!raw) return getHeaderCategoryPostSeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryPostItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategoryPostSeed();
}
return normalizeHeaderCategoryPosts(parsed); const walk = (nodes: ReturnType<typeof buildHeaderCategoryTree>) => {
} catch { nodes.forEach((item) => {
return getHeaderCategoryPostSeed(); rows.push(item);
if (item.children.length > 0) {
walk(item.children);
} }
} });
function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
HEADER_CATEGORY_POSTS_STORAGE_KEY,
JSON.stringify(normalizeHeaderCategoryPosts(items)),
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderCategoryPosts());
setIsReady(true);
}, []);
React.useEffect(() => {
if (!isReady) return;
persistHeaderCategoryPosts(items);
}, [isReady, items]);
const getPostsByCategory = React.useCallback(
(categoryId: string) => items.filter((item) => item.category_id === categoryId),
[items],
);
const removePost = React.useCallback((postId: string) => {
setItems((current) => current.filter((item) => item.id !== postId));
}, []);
return {
isReady,
getPostsByCategory,
removePost,
}; };
walk(items);
return rows;
} }
function getTypeLabel(type: HeaderCategoryType) { function HeaderCategoryPostsLoading() {
switch (type) { return Array.from({ length: 3 }).map((_, index) => (
case 'page': <TableRow
return 'Bài viết trang'; key={`loading-${index}`}
case 'news': className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'}
return 'Tin tức'; >
case 'image': <TableCell colSpan={7} className="px-4 py-4">
return 'Ảnh'; <div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
case 'category': </TableCell>
return 'Danh mục'; </TableRow>
default: ));
return type;
}
} }
export default function HeaderCategoryPostsPage() { export default function HeaderCategoryPostsPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const categoryId = String(params.categoryId ?? ''); const categoryId = String(params.categoryId ?? '');
const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const { tree, isReady: categoryReady } = useHeaderConfigModule(); const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const { isReady: postsReady, getPostsByCategory, removePost } = useHeaderCategoryPostsModule();
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null); const [ready, setReady] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null);
const flatCategories = React.useMemo(() => {
const rows: typeof tree = [];
const walk = (items: typeof tree) => { React.useEffect(() => {
items.forEach((item) => { setItems(readAdminNewsItems());
rows.push(item); setHeaderItems(readHeaderConfig());
if (item.children.length > 0) { setReady(true);
walk(item.children); }, []);
}
});
};
walk(tree); const flatCategories = React.useMemo(() => {
return rows; return flattenTree(buildHeaderCategoryTree(headerItems));
}, [tree]); }, [headerItems]);
const category = React.useMemo( const category = React.useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null, () => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories], [categoryId, flatCategories],
); );
const posts = React.useMemo(() => getPostsByCategory(categoryId), [categoryId, getPostsByCategory]); const canManagePosts = Boolean(
category && (category.type === 'page' || category.type === 'news'),
);
const categoryPosts = React.useMemo(() => {
return items
.filter((item) => item.header_category_id === categoryId)
.sort((left, right) => {
const leftFeatured = left.type === 'tintuc' && left.is_featured ? 1 : 0;
const rightFeatured = right.type === 'tintuc' && right.is_featured ? 1 : 0;
if (leftFeatured !== rightFeatured) {
return rightFeatured - leftFeatured;
}
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime;
});
}, [categoryId, items]);
const filteredPosts = React.useMemo(() => { const filteredPosts = React.useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return posts; if (!keyword) return categoryPosts;
return posts.filter( return categoryPosts.filter((item) => {
(post) => return (
post.title.toLowerCase().includes(keyword) || item.title.toLowerCase().includes(keyword) ||
post.slug.toLowerCase().includes(keyword) || item.slug.toLowerCase().includes(keyword)
post.excerpt.toLowerCase().includes(keyword),
); );
}, [posts, search]); });
}, [categoryPosts, search]);
const isSinglePostCategory = category?.type === 'page'; const isSinglePostCategory = category?.type === 'page';
const canCreatePost = Boolean( const createHref = `/admin/header-config/${categoryId}/posts/new`;
category && (category.type === 'page' || category.type === 'news' || category.type === 'image'),
);
const createLabel = category?.type === 'image' ? 'Thêm ảnh' : 'Thêm bài viết';
React.useEffect(() => { React.useEffect(() => {
if (!categoryReady || !postsReady) return; if (!ready) return;
if (!category || !canCreatePost) { if (!category || !canManagePosts) {
router.replace('/admin/header-config'); router.replace('/admin/header-config');
} }
}, [canCreatePost, category, categoryReady, postsReady, router]); }, [canManagePosts, category, ready, router]);
const stats = React.useMemo(() => {
return [
{
label: 'Tổng bài viết',
value: categoryPosts.length,
icon: <FileText className="h-4 w-4 text-[#063e8e]" />,
},
{
label: 'Đang hiển thị',
value: categoryPosts.filter((item) => !item.is_hidden).length,
icon: <FileText className="h-4 w-4 text-[#063e8e]" />,
},
{
label: 'Tin nổi bật',
value: categoryPosts.filter((item) => item.type === 'tintuc' && item.is_featured).length,
icon: <Star className="h-4 w-4 text-[#063e8e]" />,
},
];
}, [categoryPosts]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistAdminNewsItems(nextItems);
toast.success('Đã xóa bài viết');
setDeleteTarget(null);
};
if (!categoryReady || !postsReady || !category || !canCreatePost) { if (!ready || !category || !canManagePosts) {
return ( return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm"> <div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm">
Đang tải dữ liệu danh mục... Đang tải dữ liệu danh mục...
...@@ -230,167 +211,181 @@ export default function HeaderCategoryPostsPage() { ...@@ -230,167 +211,181 @@ export default function HeaderCategoryPostsPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex flex-col gap-4 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm lg:flex-row lg:items-start lg:justify-between"> <div className="flex items-center gap-3">
<div className="space-y-3">
<Button <Button
variant="ghost" type="button"
variant="outline"
size="icon"
asChild asChild
className="h-9 w-fit px-3 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
> >
<Link href="/admin/header-config"> <Link href="/admin/header-config">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Quay lại cấu hình danh mục
</Link> </Link>
</Button> </Button>
<div className="space-y-2"> <div>
<div className="flex flex-wrap items-center gap-3"> <h1 className="text-xl font-semibold text-[#063e8e]">
<h2 className="text-2xl font-semibold text-[#063e8e]">{category.name}</h2> Quản lý bài viết: {category.name}
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]"> </h1>
{getTypeLabel(category.type)} <p className="text-sm text-gray-700">
</Badge>
</div>
<p className="max-w-3xl text-sm text-gray-700">
{isSinglePostCategory {isSinglePostCategory
? 'Danh mục dạng bài viết trang chỉ cho phép gắn đúng 1 bài viết. Nếu cần thay nội dung, hãy chỉnh sửa bài hiện có hoặc xóa rồi tạo lại.' ? 'Danh mục bài viết trang chỉ quản lý một bài viết duy nhất thuộc danh mục hiển thị này.'
: category.type === 'image' : 'Quản lý toàn bộ bài viết thuộc danh mục hiển thị tương ứng trong quản lý bài viết.'}
? 'Danh mục dạng ảnh cho phép thêm nhiều nội dung và quản lý tập trung theo đúng cấu trúc header.'
: 'Danh mục dạng tin tức cho phép thêm nhiều bài viết và quản lý tập trung theo đúng cấu trúc header.'}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> {category.type === 'news' ? <AdminStatsGrid items={stats} /> : null}
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 px-4 py-3 text-sm text-gray-700">
<span className="font-semibold text-[#063e8e]">{posts.length}</span> bài viết <AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm bài viết thuộc danh mục..."
actionLabel={isSinglePostCategory ? 'Thêm bài viết trang' : 'Thêm bài viết'}
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionDisabled={isSinglePostCategory && categoryPosts.length >= 1}
onSearchChange={setSearch}
onActionClick={() => router.push(createHref)}
filters={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03] px-4 py-2 text-sm text-gray-700">
<span className="font-semibold text-[#063e8e]">{categoryPosts.length}</span>{' '}
bài viết thuộc danh mục này
</div> </div>
{canCreatePost && (!isSinglePostCategory || posts.length < 1) ? ( }
<Button asChild className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90">
<Link href={`/admin/header-config/${category.id}/posts/new`}>
<Plus className="mr-2 h-4 w-4" />
{createLabel}
</Link>
</Button>
) : (
<Button
type="button"
disabled
className="bg-[#063e8e]/30 text-white hover:bg-[#063e8e]/30"
> >
<Plus className="mr-2 h-4 w-4" /> <div className="overflow-x-auto">
{createLabel} <Table className="min-w-[980px] table-fixed">
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-700" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Tìm kiếm bài viết..."
className="border-[#063e8e]/15 bg-white pl-9 text-gray-700 placeholder:text-gray-700"
/>
</div>
{isSinglePostCategory ? (
<p className="text-sm text-gray-700">Loại danh mục này chỉ giữ 1 bài viết hiển thị.</p>
) : (
<p className="text-sm text-gray-700">
{category.type === 'image'
? 'Bạn có thể thêm nhiều mục nội dung ảnh cho danh mục này.'
: 'Bạn có thể thêm nhiều bài viết cho danh mục này.'}
</p>
)}
</div>
<div className="overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white shadow-sm">
<Table>
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="py-4 text-center text-white">Tiêu đề</TableHead> <TableHead className="w-[300px] py-4 text-center text-white">
<TableHead className="w-[180px] py-4 text-center text-white">Slug</TableHead> Tiêu đề
<TableHead className="w-[160px] py-4 text-center text-white">Ngày đăng</TableHead> </TableHead>
<TableHead className="w-[130px] py-4 text-center text-white">Hiển thị</TableHead> <TableHead className="w-[150px] py-4 text-center text-white">
<TableHead className="w-[160px] py-4 text-center text-white">Thao tác</TableHead> Hình ảnh đại diện
</TableHead>
<TableHead className="w-[160px] py-4 text-center text-white">
Loại bài viết
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày xuất bản
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày hết hạn
</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">
Hiển thị
</TableHead>
<TableHead className="w-[100px] py-4 text-center text-white">
Thao tác
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredPosts.length === 0 ? ( {!ready ? (
<HeaderCategoryPostsLoading />
) : filteredPosts.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-gray-700"> <TableCell colSpan={7} className="py-12 text-center text-sm text-gray-700">
{posts.length === 0 {categoryPosts.length === 0
? 'Danh mục này chưa có bài viết nào.' ? 'Danh mục này chưa có bài viết nào.'
: 'Không tìm thấy bài viết phù hợp.'} : 'Không có bài viết nào phù hợp.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredPosts.map((post, index) => ( filteredPosts.map((item, index) => (
<TableRow <TableRow
key={post.id} key={item.id}
className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'} className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'}
> >
<TableCell className="py-4"> <TableCell className="py-4">
<div className="flex items-start gap-4"> <div className="space-y-2">
<div className="flex h-14 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#063e8e]/10 bg-[#063e8e]/5"> <p className="line-clamp-2 text-sm font-semibold text-black">
{post.thumbnail ? ( {item.title}
// eslint-disable-next-line @next/next/no-img-element </p>
<img {item.type === 'tintuc' && item.is_featured ? (
src={post.thumbnail} <span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-xs font-medium text-[#063e8e]">
alt={post.title} <Star className="mr-1.5 h-3.5 w-3.5 fill-current" />
className="h-full w-full object-cover" Tin nổi bật
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? (
<SafeNextImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/> />
) : ( ) : (
<ImageIcon className="h-5 w-5 text-[#063e8e]" /> <div className="flex h-full items-center justify-center text-xs text-gray-700">
)} Không có ảnh
</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>
</div> </div>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center text-sm text-gray-700">{post.slug}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{ADMIN_NEWS_TYPE_LABELS[item.type]}
</Badge>
</TableCell>
<TableCell className="text-center text-sm text-gray-700"> <TableCell className="text-center text-sm text-gray-700">
{post.published_at ? dayjs(post.published_at).format('DD/MM/YYYY') : '-'} {formatDateTime(item.published_at)}
</TableCell> </TableCell>
<TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.expired_at)}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{post.is_active ? ( {item.is_hidden ? (
<span className="inline-flex items-center gap-2 text-sm font-medium text-[#063e8e]"> <span className="inline-flex items-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700">
<Eye className="h-4 w-4" /> <EyeOff className="mr-1.5 h-3.5 w-3.5" />
Hiển thị Ẩn
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-2 text-sm font-medium text-gray-700"> <span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]">
<EyeOff className="h-4 w-4" /> Hiển thị
Ẩn
</span> </span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-1"> <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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
asChild asChild
className="text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" className="text-gray-700 focus:text-[#063e8e]"
> >
<Link href={`/admin/header-config/${category.id}/posts/${post.id}`}> <Link href={`/admin/header-config/${categoryId}/posts/${item.id}`}>
<Pencil className="h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link> </Link>
</Button> </DropdownMenuItem>
<Button <DropdownMenuSeparator />
variant="ghost" <DropdownMenuItem
size="icon" className="text-gray-700 focus:text-[#063e8e]"
className="text-gray-700 hover:bg-red-50 hover:text-red-600" onClick={() => setDeleteTarget(item)}
onClick={() => setDeleteId(post.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
</Button> Xóa
</div> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
...@@ -398,33 +393,23 @@ export default function HeaderCategoryPostsPage() { ...@@ -398,33 +393,23 @@ export default function HeaderCategoryPostsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</AdminTableLayout>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent className="border-[#063e8e]/15 bg-white"> <AdminDeleteDialog
<AlertDialogHeader> open={!!deleteTarget}
<AlertDialogTitle className="text-[#063e8e]">Xóa bài viết</AlertDialogTitle> title="Xóa bài viết"
<AlertDialogDescription className="text-gray-700"> description={
Bài viết này sẽ bị xóa khỏi danh mục hiện tại. deleteTarget ? (
</AlertDialogDescription> <>
</AlertDialogHeader> Bài viết <strong>{deleteTarget.title}</strong> sẽ bị xóa khỏi dữ liệu quản trị.
<AlertDialogFooter> </>
<AlertDialogCancel className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"> ) : null
Hủy }
</AlertDialogCancel> onOpenChange={(open) => {
<AlertDialogAction if (!open) setDeleteTarget(null);
className="bg-red-600 text-white hover:bg-red-700"
onClick={() => {
if (!deleteId) return;
removePost(deleteId);
toast.success('Đã xóa bài viết');
setDeleteId(null);
}} }}
> onConfirm={handleDelete}
Xóa />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
...@@ -22,9 +21,8 @@ import { ...@@ -22,9 +21,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
headerArticleCategoryOptions, type HeaderCategoryTreeItem,
HeaderCategoryTreeItem, type HeaderCategoryType,
HeaderCategoryType,
toSlug, toSlug,
} from "@/mockdata/header-config"; } from "@/mockdata/header-config";
...@@ -38,7 +36,7 @@ export interface HeaderCategoryFormValues { ...@@ -38,7 +36,7 @@ export interface HeaderCategoryFormValues {
parent_id: string; parent_id: string;
type: HeaderCategoryType; type: HeaderCategoryType;
description: string; description: string;
category_ids: string[]; tagsearch: string;
} }
interface HeaderCategoryFormDialogProps { interface HeaderCategoryFormDialogProps {
...@@ -56,7 +54,6 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [ ...@@ -56,7 +54,6 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [
{ value: "category", label: "Danh mục" }, { value: "category", label: "Danh mục" },
{ value: "page", label: "Bài viết trang" }, { value: "page", label: "Bài viết trang" },
{ value: "news", label: "Tin tức" }, { value: "news", label: "Tin tức" },
{ value: "image", label: "Ảnh" },
]; ];
const fieldClassName = const fieldClassName =
...@@ -67,7 +64,8 @@ const selectTriggerClassName = ...@@ -67,7 +64,8 @@ const selectTriggerClassName =
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700"; const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"; const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
export function HeaderCategoryFormDialog({ export function HeaderCategoryFormDialog({
mode, mode,
...@@ -99,6 +97,11 @@ export function HeaderCategoryFormDialog({ ...@@ -99,6 +97,11 @@ export function HeaderCategoryFormDialog({
})); }));
}; };
const searchTags = values.tagsearch
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto border-[#063e8e]/15 bg-white text-gray-700 shadow-xl"> <DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto border-[#063e8e]/15 bg-white text-gray-700 shadow-xl">
...@@ -111,7 +114,7 @@ export function HeaderCategoryFormDialog({ ...@@ -111,7 +114,7 @@ export function HeaderCategoryFormDialog({
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
<div> <div>
<Label className="mb-1.5 block text-gray-700">Tên danh mục *</Label> <Label className="mb-1.5 block text-gray-700">Tên danh mục <span className="text-red-600">*</span></Label>
<Input <Input
value={values.name} value={values.name}
onChange={(event) => handleNameChange(event.target.value)} onChange={(event) => handleNameChange(event.target.value)}
...@@ -121,7 +124,7 @@ export function HeaderCategoryFormDialog({ ...@@ -121,7 +124,7 @@ export function HeaderCategoryFormDialog({
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-gray-700">Thể loại *</Label> <Label className="mb-1.5 block text-gray-700">Thể loại <span className="text-red-600">*</span></Label>
<Select <Select
value={values.type} value={values.type}
onValueChange={(value) => onValueChange={(value) =>
...@@ -182,7 +185,7 @@ export function HeaderCategoryFormDialog({ ...@@ -182,7 +185,7 @@ export function HeaderCategoryFormDialog({
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-gray-700">Thứ tự</Label> <Label className="mb-1.5 block text-gray-700">Thứ tự <span className="text-red-600">*</span></Label>
<Input <Input
type="number" type="number"
min="0" min="0"
...@@ -194,7 +197,7 @@ export function HeaderCategoryFormDialog({ ...@@ -194,7 +197,7 @@ export function HeaderCategoryFormDialog({
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-gray-700">Slug</Label> <Label className="mb-1.5 block text-gray-700">Slug <span className="text-red-600">*</span></Label>
<Input <Input
value={values.slug} value={values.slug}
onChange={(event) => setField("slug", event.target.value)} onChange={(event) => setField("slug", event.target.value)}
...@@ -214,33 +217,28 @@ export function HeaderCategoryFormDialog({ ...@@ -214,33 +217,28 @@ export function HeaderCategoryFormDialog({
/> />
</div> </div>
{values.type === "news" ? ( {mode === "edit" && values.type === "news" ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Thể loại bài viết</Label> <Label className="mb-1.5 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 rounded-lg border border-[#063e8e]/15 p-4 md:grid-cols-2"> <Textarea
{headerArticleCategoryOptions.map((category) => ( rows={3}
<label value={values.tagsearch}
key={category.id} onChange={(event) => setField("tagsearch", event.target.value)}
className="flex items-center gap-3 rounded-md border border-[#063e8e]/10 px-3 py-2" placeholder="Nhập tag tìm kiếm, ngăn cách bằng dấu phẩy"
> className={fieldClassName}
<Checkbox
checked={values.category_ids.includes(category.id)}
onCheckedChange={(checked) => {
if (checked) {
setField("category_ids", [...values.category_ids, category.id]);
return;
}
setField(
"category_ids",
values.category_ids.filter((id) => id !== category.id),
);
}}
/> />
<span className="text-sm text-gray-700">{category.name}</span> {searchTags.length > 0 ? (
</label> <div className="mt-3 flex flex-wrap gap-2">
{searchTags.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-[#063e8e]/15 bg-[#063e8e]/[0.04] px-3 py-1 text-sm text-gray-700"
>
{item}
</span>
))} ))}
</div> </div>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
...@@ -253,7 +251,10 @@ export function HeaderCategoryFormDialog({ ...@@ -253,7 +251,10 @@ export function HeaderCategoryFormDialog({
> >
Hủy Hủy
</Button> </Button>
<Button className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" onClick={onSubmit}> <Button
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={onSubmit}
>
{mode === "create" ? "Lưu danh mục" : "Cập nhật danh mục"} {mode === "create" ? "Lưu danh mục" : "Cập nhật danh mục"}
</Button> </Button>
</DialogFooter> </DialogFooter>
......
...@@ -7,7 +7,6 @@ import { ...@@ -7,7 +7,6 @@ import {
ChevronRight, ChevronRight,
Edit, Edit,
ExternalLink, ExternalLink,
FileImage,
FileText, FileText,
FolderTree, FolderTree,
MoreHorizontal, MoreHorizontal,
...@@ -33,7 +32,10 @@ import { ...@@ -33,7 +32,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { HeaderCategoryTreeItem, getHeaderCategoryTypeLabel } from "@/mockdata/header-config"; import {
type HeaderCategoryTreeItem,
getHeaderCategoryTypeLabel,
} from "@/mockdata/header-config";
export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & { export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
depth: number; depth: number;
...@@ -69,9 +71,8 @@ function getDisplaySortOrder(item: HeaderCategoryFlatRow, rows: HeaderCategoryFl ...@@ -69,9 +71,8 @@ function getDisplaySortOrder(item: HeaderCategoryFlatRow, rows: HeaderCategoryFl
function getTypeIcon(type: HeaderCategoryTreeItem["type"]) { function getTypeIcon(type: HeaderCategoryTreeItem["type"]) {
switch (type) { switch (type) {
case "news": case "news":
case "page":
return <FileText className="h-4 w-4 text-[#063e8e]" />; return <FileText className="h-4 w-4 text-[#063e8e]" />;
case "image":
return <FileImage className="h-4 w-4 text-[#063e8e]" />;
default: default:
return <FolderTree className="h-4 w-4 text-[#063e8e]" />; return <FolderTree className="h-4 w-4 text-[#063e8e]" />;
} }
...@@ -160,9 +161,7 @@ export function HeaderCategoryTable({ ...@@ -160,9 +161,7 @@ export function HeaderCategoryTable({
const hasChildren = rows.some((entry) => entry.parentId === item.id); const hasChildren = rows.some((entry) => entry.parentId === item.id);
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 = const canManagePosts = item.type === "page" || item.type === "news";
item.type === "page" || item.type === "news" || item.type === "image";
const createContentLabel = item.type === "image" ? "Thêm ảnh" : "Thêm bài viết";
return ( return (
<TableRow <TableRow
...@@ -191,11 +190,13 @@ export function HeaderCategoryTable({ ...@@ -191,11 +190,13 @@ export function HeaderCategoryTable({
<div className="truncate font-medium text-black">{item.name}</div> <div className="truncate font-medium text-black">{item.name}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="w-[180px] text-center"> <TableCell className="w-[180px] text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{getHeaderCategoryTypeLabel(item.type)} {getHeaderCategoryTypeLabel(item.type)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="w-[140px] text-center font-medium text-black"> <TableCell className="w-[140px] text-center font-medium text-black">
<span <span
className={ className={
...@@ -207,6 +208,7 @@ export function HeaderCategoryTable({ ...@@ -207,6 +208,7 @@ export function HeaderCategoryTable({
{getDisplaySortOrder(item, rows)} {getDisplaySortOrder(item, rows)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="w-[280px] text-sm text-gray-700"> <TableCell className="w-[280px] text-sm text-gray-700">
<div className="mx-auto flex max-w-[220px] items-center justify-center gap-2"> <div className="mx-auto flex max-w-[220px] items-center justify-center gap-2">
<span className="block max-w-[180px] truncate"> <span className="block max-w-[180px] truncate">
...@@ -217,6 +219,7 @@ export function HeaderCategoryTable({ ...@@ -217,6 +219,7 @@ export function HeaderCategoryTable({
) : null} ) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell className="w-[120px] text-center"> <TableCell className="w-[120px] text-center">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
...@@ -241,9 +244,9 @@ export function HeaderCategoryTable({ ...@@ -241,9 +244,9 @@ export function HeaderCategoryTable({
asChild asChild
className="text-gray-700 focus:text-[#063e8e]" className="text-gray-700 focus:text-[#063e8e]"
> >
<Link href={`/admin/header-config/${item.id}/posts/new`}> <Link href={`/admin/header-config/${item.id}/posts`}>
<Plus className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
{createContentLabel} Quản lý bài viết
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
......
...@@ -29,7 +29,7 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = { ...@@ -29,7 +29,7 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
parent_id: '', parent_id: '',
type: 'page', type: 'page',
description: '', description: '',
category_ids: [], tagsearch: '',
}; };
function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValues { function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValues {
...@@ -43,10 +43,26 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue ...@@ -43,10 +43,26 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
parent_id: item.parent_id ?? '', parent_id: item.parent_id ?? '',
type: item.type, type: item.type,
description: item.description ?? '', description: item.description ?? '',
category_ids: item.category_ids ?? [], tagsearch: (item.tagsearch_values ?? []).join(', '),
}; };
} }
function parseTagsearch(value: string) {
const seen = new Set<string>();
return value
.split(',')
.map((item) => item.trim())
.filter((item) => {
if (!item) return false;
const key = item.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function getInitialHeaderConfig() { function getInitialHeaderConfig() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return getHeaderCategorySeed(); return getHeaderCategorySeed();
...@@ -126,7 +142,8 @@ function useHeaderConfigModule() { ...@@ -126,7 +142,8 @@ function useHeaderConfigModule() {
is_article: values.type === 'news', is_article: values.type === 'news',
parent_id: values.parent_id || null, parent_id: values.parent_id || null,
level: 1, level: 1,
category_ids: values.type === 'news' ? values.category_ids : [], category_ids: [],
tagsearch_values: values.type === 'news' ? parseTagsearch(values.tagsearch) : [],
description: values.description.trim(), description: values.description.trim(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
...@@ -150,7 +167,8 @@ function useHeaderConfigModule() { ...@@ -150,7 +167,8 @@ function useHeaderConfigModule() {
type: values.type, type: values.type,
is_article: values.type === 'news', is_article: values.type === 'news',
parent_id: values.parent_id || null, parent_id: values.parent_id || null,
category_ids: values.type === 'news' ? values.category_ids : [], category_ids: [],
tagsearch_values: values.type === 'news' ? parseTagsearch(values.tagsearch) : [],
description: values.description.trim(), description: values.description.trim(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}); });
......
'use client'; import { AdminNewsForm } from "@/components/admin/news-form";
import React, { useEffect, useState, useTransition } from 'react'; interface AdminNewsDetailPageProps {
import { useParams, useRouter } from 'next/navigation'; params: Promise<{
import Link from 'next/link'; id: string;
import { }>;
useGetNewsId,
usePostNews,
usePutNewsId,
getGetNewsAdminQueryKey,
} from '@/api/endpoints/news';
import { useGetCategory } from '@/api/endpoints/category';
import { useGetNewsPageConfigGetHierarchical } from '@/api/endpoints/news-page-config';
import { GetCategoryAdminResponseType } from '@/api/types/category';
import {
GetNewsPageConfigResponseType,
NewsPageConfigItem,
} from '@/api/types/news-page-config';
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 { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Save } from 'lucide-react';
import { Spinner } from '@/components/ui';
// Flatten news page config tree for select options
function flattenTree(
node: NewsPageConfigItem,
depth = 0,
): { id: string; label: string }[] {
const prefix = ' '.repeat(depth);
const self = { id: node.id, label: `${prefix}${node.name}` };
const children = (node.children ?? []).flatMap((c) => flattenTree(c, depth + 1));
return [self, ...children];
} }
export default function NewsFormPage() { export default async function AdminNewsDetailPage({
const params = useParams(); params,
const router = useRouter(); }: AdminNewsDetailPageProps) {
const qc = useQueryClient(); const { id } = await params;
const id = params?.id as string;
const isNew = id === 'new';
const [, startTransition] = useTransition();
const [form, setForm] = useState({
title: '',
thumbnail: '',
external_link: '',
description: '',
release_at: '',
is_active: true,
category: '',
page_config_id: '',
});
// Fetch existing news when editing
const { data: newsData, isLoading: newsLoading } = useGetNewsId(isNew ? '' : id, {
query: { enabled: !isNew && !!id },
});
useEffect(() => {
const d = (newsData as Record<string, unknown>)?.responseData as Record<string, unknown> | undefined
?? (newsData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
if (!d) return;
// Intentional: populate form when data loads
startTransition(() => setForm({
title: (d.title as string) ?? '',
thumbnail: (d.thumbnail as string) ?? '',
external_link: (d.external_link as string) ?? '',
description: (d.description as string) ?? '',
release_at: d.release_at ? (d.release_at as string).slice(0, 10) : '',
is_active: (d.is_active as boolean) ?? true,
category: (d.category as string) ?? '',
page_config_id: ((d.page_config as Record<string, string>)?.id) ?? (d.page_config_id as string) ?? '',
}));
}, [newsData]);
// Categories
const { data: catData } = useGetCategory<GetCategoryAdminResponseType>({ pageSize: '100' });
const categories = catData?.responseData?.rows ?? [];
// Page config tree
const { data: configData } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>();
const configRoot = configData?.responseData;
const configOptions = configRoot ? flattenTree(configRoot) : [];
// Mutations
const { mutate: create, isPending: creating } = usePostNews({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
router.push('/admin/news');
},
},
});
const { mutate: update, isPending: updating } = usePutNewsId({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
router.push('/admin/news');
},
},
});
const isPending = creating || updating;
const setField = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload = {
title: form.title,
thumbnail: form.thumbnail || undefined,
external_link: form.external_link || undefined,
description: form.description,
release_at: form.release_at || undefined,
is_active: form.is_active,
category: form.category || undefined,
page_config_id: form.page_config_id || undefined,
};
if (isNew) {
create({ data: [payload] });
} else {
update({ id, data: payload });
}
};
if (!isNew && newsLoading) {
return (
<div className="flex justify-center py-20">
<Spinner />
</div>
);
}
return (
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/news">
<ArrowLeft size={18} />
</Link>
</Button>
<div>
<h2 className="text-xl font-bold text-gray-800">
{isNew ? 'Thêm bài viết mới' : 'Chỉnh sửa bài viết'}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
{isNew ? 'Điền thông tin để tạo bài viết mới.' : `Đang sửa bài viết #${id}`}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-lg border shadow-sm p-6 space-y-5">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor="title">Tiêu đề *</Label>
<Input
id="title"
required
value={form.title}
onChange={(e) => setField('title', e.target.value)}
placeholder="Nhập tiêu đề bài viết..."
/>
</div>
{/* Thumbnail */}
<div className="space-y-1.5">
<Label htmlFor="thumbnail">URL ảnh thumbnail</Label>
<Input
id="thumbnail"
value={form.thumbnail}
onChange={(e) => setField('thumbnail', e.target.value)}
placeholder="https://..."
/>
{form.thumbnail && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.thumbnail}
alt="preview"
className="mt-2 h-32 w-auto rounded-md border object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
{/* External link */}
<div className="space-y-1.5">
<Label htmlFor="ext-link">Đường dẫn ngoài (nếu có)</Label>
<Input
id="ext-link"
value={form.external_link}
onChange={(e) => setField('external_link', e.target.value)}
placeholder="https://..."
/>
</div>
{/* Category */}
<div className="space-y-1.5">
<Label>Thể loại</Label>
<Select value={form.category} onValueChange={(v) => setField('category', v)}>
<SelectTrigger>
<SelectValue placeholder="Chọn thể loại..." />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Page config */}
<div className="space-y-1.5">
<Label>Danh mục menu (page config)</Label>
<Select value={form.page_config_id} onValueChange={(v) => setField('page_config_id', v)}>
<SelectTrigger>
<SelectValue placeholder="Chọn danh mục..." />
</SelectTrigger>
<SelectContent className="max-h-60">
{configOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Release date */}
<div className="space-y-1.5">
<Label htmlFor="release-at">Ngày đăng</Label>
<Input
id="release-at"
type="date"
value={form.release_at}
onChange={(e) => setField('release_at', e.target.value)}
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="desc">Nội dung / Mô tả (HTML)</Label>
<Textarea
id="desc"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
placeholder="<p>Nội dung bài viết...</p>"
rows={8}
className="font-mono text-sm"
/>
</div>
{/* Is active */}
<div className="flex items-center gap-3">
<Switch
id="is-active"
checked={form.is_active}
onCheckedChange={(v) => setField('is_active', v)}
/>
<Label htmlFor="is-active" className="cursor-pointer">
Hiển thị bài viết
</Label>
</div>
{/* Submit */} return <AdminNewsForm newsId={id} />;
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" type="button" asChild>
<Link href="/admin/news">Huỷ</Link>
</Button>
<Button type="submit" disabled={isPending}>
<Save size={15} className="mr-1" />
{isPending ? 'Đang lưu...' : isNew ? 'Tạo bài viết' : 'Cập nhật'}
</Button>
</div>
</form>
</div>
);
} }
'use client'; "use client";
import React, { useState } from 'react'; import * as React from "react";
import Link from 'next/link'; import dayjs from "dayjs";
import { import {
useGetNewsAdmin, Edit,
useDeleteNewsId, EyeOff,
getGetNewsAdminQueryKey, MoreHorizontal,
} from '@/api/endpoints/news'; Plus,
import { GetNewsResponseType } from '@/api/types/news'; Star,
import { useQueryClient } from '@tanstack/react-query'; Tag,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -16,177 +41,399 @@ import { ...@@ -16,177 +41,399 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from "@/components/ui/table";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { import {
AlertDialog, ADMIN_NEWS_TYPE_LABELS,
AlertDialogAction, ADMIN_NEWS_TYPE_OPTIONS,
AlertDialogCancel, type AdminNewsItem,
AlertDialogContent, persistAdminNewsItems,
AlertDialogDescription, readAdminNewsItems,
AlertDialogFooter, } from "@/mockdata/admin-news";
AlertDialogHeader, import {
AlertDialogTitle, type HeaderCategoryItem,
} from '@/components/ui/alert-dialog'; getHeaderCategorySeed,
import { Eye, EyeOff, Pencil, Plus, Search, Trash2 } from 'lucide-react'; HEADER_CONFIG_STORAGE_KEY,
import dayjs from 'dayjs'; normalizeHeaderCategories,
import { Spinner } from '@/components/ui'; } from "@/mockdata/header-config";
function DeleteConfirm({ item, onClose }: { item: { id: string; title: string }; onClose: () => void }) {
const qc = useQueryClient();
const { mutate: del, isPending } = useDeleteNewsId({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
onClose();
},
},
});
return ( const selectTriggerClassName =
<AlertDialog open onOpenChange={() => onClose()}> "w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]";
<AlertDialogContent>
<AlertDialogHeader> const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
<AlertDialogTitle>Xoá bài viết?</AlertDialogTitle>
<AlertDialogDescription> const selectItemClassName =
Bài viết <strong>"{item.title}"</strong> sẽ bị xoá vĩnh viễn. "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
</AlertDialogDescription>
</AlertDialogHeader> function readHeaderConfig() {
<AlertDialogFooter> if (typeof window === "undefined") {
<AlertDialogCancel>Huỷ</AlertDialogCancel> return getHeaderCategorySeed();
<AlertDialogAction }
className="bg-red-600 hover:bg-red-700"
onClick={() => del({ id: String(item.id) })} const raw = window.localStorage.getItem(HEADER_CONFIG_STORAGE_KEY);
disabled={isPending} if (!raw) return getHeaderCategorySeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategorySeed();
}
return normalizeHeaderCategories(parsed);
} catch {
return getHeaderCategorySeed();
}
}
function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
}
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function AdminNewsTableLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
> >
{isPending ? 'Đang xoá...' : 'Xoá'} <TableCell colSpan={8} className="px-4 py-4">
</AlertDialogAction> <div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</AlertDialogFooter> </TableCell>
</AlertDialogContent> </TableRow>
</AlertDialog> ));
);
} }
export default function NewsPage() { export default function AdminNewsPage() {
const [search, setSearch] = useState(''); const router = useRouter();
const [page, setPage] = useState(1); const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const [deleteItem, setDeleteItem] = useState<{ id: string; title: string } | null>(null); const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const pageSize = 20; const [search, setSearch] = React.useState("");
const [typeFilter, setTypeFilter] = React.useState("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
const [statusFilter, setStatusFilter] = React.useState("all");
const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
setItems(readAdminNewsItems());
setHeaderItems(readHeaderConfig());
setReady(true);
}, []);
const { data, isLoading } = useGetNewsAdmin<GetNewsResponseType>({ const categoryOptions = React.useMemo(() => {
currentPage: String(page), return headerItems.filter((item) => item.type === "news" || item.type === "page");
pageSize: String(pageSize), }, [headerItems]);
filters: search ? `title@=${search}` : undefined,
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
return items
.filter((item) => {
const categoryName =
headerItems.find((category) => category.id === item.header_category_id)?.name ?? "";
const matchesKeyword =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.slug.toLowerCase().includes(keyword) ||
stripHtml(item.summary).toLowerCase().includes(keyword) ||
categoryName.toLowerCase().includes(keyword);
const matchesType = typeFilter === "all" || item.type === typeFilter;
const matchesCategory =
categoryFilter === "all" || item.header_category_id === categoryFilter;
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "visible" && !item.is_hidden) ||
(statusFilter === "hidden" && item.is_hidden);
return matchesKeyword && matchesType && matchesCategory && matchesStatus;
})
.sort((left, right) => {
const leftFeatured = left.type === "tintuc" && left.is_featured ? 1 : 0;
const rightFeatured = right.type === "tintuc" && right.is_featured ? 1 : 0;
if (leftFeatured !== rightFeatured) {
return rightFeatured - leftFeatured;
}
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime;
}); });
}, [categoryFilter, headerItems, items, search, statusFilter, typeFilter]);
const stats = React.useMemo(() => {
return [
{
label: "Tổng bài viết",
value: items.length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Đang hiển thị",
value: items.filter((item) => !item.is_hidden).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Tin nổi bật",
value: items.filter((item) => item.type === "tintuc" && item.is_featured).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
];
}, [items]);
const handleDelete = () => {
if (!deleteTarget) return;
const rows = data?.responseData?.rows ?? []; const nextItems = items.filter((item) => item.id !== deleteTarget.id);
const totalPages = data?.responseData?.totalPages ?? 1; setItems(nextItems);
persistAdminNewsItems(nextItems);
toast.success("Đã xóa bài viết");
setDeleteTarget(null);
};
return ( return (
<div> <div className="space-y-8">
<div className="flex items-center justify-between mb-6"> <AdminStatsGrid items={stats} />
<div>
<h2 className="text-xl font-bold text-gray-800">Danh sách bài viết</h2>
<p className="text-sm text-gray-500 mt-1">Tất cả bài viết trong hệ thống.</p>
</div>
<Button asChild>
<Link href="/admin/news/new">
<Plus size={16} className="mr-1" /> Thêm bài viết
</Link>
</Button>
</div>
<div className="relative w-72 mb-4"> <AdminTableLayout
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> searchValue={search}
<Input searchPlaceholder="Tìm kiếm bài viết..."
className="pl-9" actionLabel="Thêm bài viết"
placeholder="Tìm kiếm tiêu đề..." actionIcon={<Plus className="mr-2 h-4 w-4" />}
value={search} onSearchChange={setSearch}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onActionClick={() => router.push("/admin/news/new")}
/> filters={
</div> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Loại bài viết" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả loại bài viết
</SelectItem>
{ADMIN_NEWS_TYPE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={selectItemClassName}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden"> <Select value={categoryFilter} onValueChange={setCategoryFilter}>
<Table> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Danh mục hiển thị" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả danh mục
</SelectItem>
{categoryOptions.map((category) => (
<SelectItem
key={category.id}
value={category.id}
className={selectItemClassName}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Trạng thái" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả trạng thái
</SelectItem>
<SelectItem value="visible" className={selectItemClassName}>
Đang hiển thị
</SelectItem>
<SelectItem value="hidden" className={selectItemClassName}>
Đang ẩn
</SelectItem>
</SelectContent>
</Select>
</div>
}
>
<div className="overflow-x-auto">
<Table className="min-w-[1250px] table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-8">#</TableHead> <TableHead className="w-[260px] py-4 text-center text-white">
<TableHead>Tiêu đề</TableHead> Tiêu đề
<TableHead>Thể loại</TableHead> </TableHead>
<TableHead>Danh mục</TableHead> <TableHead className="w-[140px] py-4 text-center text-white">
<TableHead className="w-16 text-center">Hiển thị</TableHead> Hình ảnh đại diện
<TableHead>Ngày đăng</TableHead> </TableHead>
<TableHead className="w-24 text-right">Thao tác</TableHead> <TableHead className="w-40 py-4 text-center text-white">
Loại bài viết
</TableHead>
<TableHead className="w-[190px] py-4 text-center text-white">
Danh mục hiển thị
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày xuất bản
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày hết hạn
</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">
Hiển thị
</TableHead>
<TableHead className="w-[100px] py-4 text-center text-white">
Thao tác
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {!ready ? (
<AdminNewsTableLoading />
) : filteredItems.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-10"><Spinner /></TableCell> <TableCell colSpan={8} className="py-12 text-center text-sm text-gray-700">
</TableRow> Không có bài viết nào phù hợp.
) : rows.length === 0 ? ( </TableCell>
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10 text-sm">Chưa có bài viết nào.</TableCell>
</TableRow> </TableRow>
) : rows.map((news: Record<string, any>, idx: number) => ( ) : (
<TableRow key={news.id}> filteredItems.map((item, index) => {
<TableCell className="text-gray-400 text-sm">{(page - 1) * pageSize + idx + 1}</TableCell> const category = headerItems.find((entry) => entry.id === item.header_category_id);
<TableCell className="max-w-xs">
<p className="font-medium text-sm truncate">{news.title}</p> return (
{news.external_link && ( <TableRow
<p className="text-xs text-blue-400 truncate">{news.external_link}</p> key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="py-4">
<div className="space-y-2">
<p className="line-clamp-2 text-sm font-semibold text-black">
{item.title}
</p>
{item.type === "tintuc" && item.is_featured ? (
<span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-xs font-medium text-[#063e8e]">
<Star className="mr-1.5 h-3.5 w-3.5 fill-current" />
Tin nổi bật
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/3">
{item.thumbnail ? (
<SafeNextImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-gray-700">
Không có ảnh
</div>
)} )}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{ADMIN_NEWS_TYPE_LABELS[item.type]}
</Badge>
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{category?.name || "—"}
</TableCell> </TableCell>
<TableCell>
{news.category?.name ? ( <TableCell className="text-center text-sm text-gray-700">
<Badge variant="secondary" className="text-xs">{news.category.name}</Badge> {formatDateTime(item.published_at)}
) : <span className="text-gray-300 text-xs"></span>}
</TableCell> </TableCell>
<TableCell className="text-sm text-gray-500 max-w-[120px] truncate">
{news.pageConfig?.name || '—'} <TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.expired_at)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{news.is_active ? ( {item.is_hidden ? (
<Eye size={16} className="inline text-green-500" /> <span className="inline-flex items-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700">
<EyeOff className="mr-1.5 h-3.5 w-3.5" />
Ẩn
</span>
) : ( ) : (
<EyeOff size={16} className="inline text-gray-300" /> <span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]">
Hiển thị
</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-gray-400 text-sm">
{news.release_at ? dayjs(news.release_at).format('DD/MM/YYYY') : '—'} <TableCell className="text-center">
</TableCell> <DropdownMenu>
<TableCell className="text-right"> <DropdownMenuTrigger asChild>
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" asChild>
<Link href={`/admin/news/${news.id}`}><Pencil size={15} /></Link>
</Button>
<Button <Button
size="icon"
variant="ghost" variant="ghost"
className="text-red-500 hover:text-red-600" className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDeleteItem({ id: String(news.id), title: String(news.title) })}
> >
<Trash2 size={15} /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</div> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/news/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</AdminTableLayout>
{totalPages > 1 && ( <AdminDeleteDialog
<div className="flex justify-end gap-2 mt-4"> open={!!deleteTarget}
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Trước</Button> title="Xóa bài viết"
<span className="text-sm text-gray-500 self-center">{page} / {totalPages}</span> description={
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Sau</Button> deleteTarget ? (
</div> <>
)} Bài viết <strong>{deleteTarget.title}</strong> sẽ bị xóa khỏi dữ liệu quản trị.
</>
{deleteItem && <DeleteConfirm item={deleteItem} onClose={() => setDeleteItem(null)} />} ) : null
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDelete}
/>
</div> </div>
); );
} }
...@@ -12,6 +12,7 @@ interface AdminTableLayoutProps { ...@@ -12,6 +12,7 @@ interface AdminTableLayoutProps {
actionIcon?: React.ReactNode; actionIcon?: React.ReactNode;
actionDisabled?: boolean; actionDisabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
filters?: React.ReactNode;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onActionClick?: () => void; onActionClick?: () => void;
} }
...@@ -23,18 +24,22 @@ export function AdminTableLayout({ ...@@ -23,18 +24,22 @@ export function AdminTableLayout({
actionIcon, actionIcon,
actionDisabled = false, actionDisabled = false,
children, children,
filters,
onSearchChange, onSearchChange,
onActionClick, onActionClick,
}: AdminTableLayoutProps) { }: AdminTableLayoutProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<Input <Input
value={searchValue} value={searchValue}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
onChange={(event) => onSearchChange(event.target.value)} onChange={(event) => onSearchChange(event.target.value)}
className="max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700" className="max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/> />
{filters}
</div>
{actionLabel ? ( {actionLabel ? (
<Button <Button
......
"use client";
import * as React from "react";
import { ImagePlus, Search, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import {
AdminMediaItem,
createAdminMediaId,
persistAdminMediaItems,
readAdminMediaItems,
} from "@/mockdata/admin-news";
import { cn } from "@/lib/utils";
interface AdminImagePickerProps {
open: boolean;
selectedId?: string | null;
onOpenChange: (open: boolean) => void;
onSelect: (item: AdminMediaItem) => void;
}
function formatFileSize(size: number) {
if (!size) return "Ảnh hệ thống";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
export function AdminImagePicker({
open,
selectedId,
onOpenChange,
onSelect,
}: AdminImagePickerProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [search, setSearch] = React.useState("");
const [items, setItems] = React.useState<AdminMediaItem[]>([]);
React.useEffect(() => {
if (!open) return;
setItems(readAdminMediaItems());
}, [open]);
const visibleItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => {
return (
item.name.toLowerCase().includes(keyword) ||
item.alt.toLowerCase().includes(keyword) ||
item.url.toLowerCase().includes(keyword)
);
});
}, [items, search]);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const nextItem: AdminMediaItem = {
id: createAdminMediaId(),
name: file.name,
alt: file.name.replace(/\.[^.]+$/, ""),
url: typeof reader.result === "string" ? reader.result : "/img-error.png",
mime: file.type || "image/*",
size: file.size,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
source: "upload",
};
const nextItems = [nextItem, ...items];
setItems(nextItems);
persistAdminMediaItems(nextItems);
onSelect(nextItem);
onOpenChange(false);
};
reader.readAsDataURL(file);
event.target.value = "";
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] max-w-5xl overflow-hidden border-[#063e8e]/15 bg-white p-0">
<DialogHeader className="border-b border-[#063e8e]/10 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="text-xl font-semibold text-black">
Thư viện hình ảnh
</DialogTitle>
<DialogDescription className="mt-1 text-sm text-gray-700">
Chọn ảnh có sẵn hoặc tải thêm ảnh mới cho bài viết.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex flex-col gap-4 border-b border-[#063e8e]/10 px-6 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-700" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Tìm kiếm hình ảnh..."
className="border-[#063e8e]/15 bg-white pl-9 text-gray-700 placeholder:text-gray-700"
/>
</div>
<div className="flex items-center gap-3">
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
onClick={() => inputRef.current?.click()}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Upload className="mr-2 h-4 w-4" />
Tải hình ảnh
</Button>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto px-6 py-6">
{visibleItems.length === 0 ? (
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-[#063e8e]/15 bg-[#063e8e]/[0.03] px-6 text-center">
<ImagePlus className="mb-3 h-10 w-10 text-[#063e8e]" />
<p className="text-base font-medium text-black">Chưa có hình ảnh phù hợp</p>
<p className="mt-1 text-sm text-gray-700">
Hãy thử từ khóa khác hoặc tải thêm hình ảnh vào thư viện.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{visibleItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onSelect(item);
onOpenChange(false);
}}
className={cn(
"group overflow-hidden rounded-2xl border bg-white text-left transition-all",
item.id === selectedId
? "border-[#063e8e] shadow-[0_0_0_2px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 hover:border-[#063e8e]/40 hover:shadow-sm",
)}
>
<div className="relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]">
<SafeNextImage
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover transition duration-300 group-hover:scale-[1.02]"
/>
{item.id === selectedId ? (
<div className="absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white">
Đã chọn
</div>
) : null}
</div>
<div className="space-y-1 px-4 py-3">
<p className="line-clamp-1 text-sm font-medium text-black">{item.name}</p>
<div className="flex items-center justify-between gap-2 text-xs text-gray-700">
<span>{formatFileSize(item.size)}</span>
<span>{item.source === "upload" ? "Tải lên" : "Hệ thống"}</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
<div className="flex justify-end border-t border-[#063e8e]/10 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<X className="mr-2 h-4 w-4" />
Đóng
</Button>
</div>
</DialogContent>
</Dialog>
);
}
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
type AdminMediaItem,
type AdminNewsFormValues,
type AdminNewsImageRef,
type AdminNewsItem,
type AdminNewsType,
ADMIN_NEWS_TYPE_OPTIONS,
cloneAdminNewsFormValues,
createAdminNewsId,
persistAdminNewsItems,
readAdminNewsItems,
resolveAdminNewsType,
slugifyAdminNews,
} from "@/mockdata/admin-news";
import {
type HeaderCategoryItem,
type HeaderCategoryTreeItem,
HEADER_CONFIG_STORAGE_KEY,
buildHeaderCategoryTree,
getHeaderCategorySeed,
normalizeHeaderCategories,
} from "@/mockdata/header-config";
interface AdminNewsFormProps {
newsId?: string;
presetHeaderCategoryId?: string;
lockedType?: AdminNewsType;
returnPath?: string;
}
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
const readOnlyFieldClassName =
"border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700";
const selectTriggerClassName =
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function readHeaderConfig() {
if (typeof window === "undefined") {
return getHeaderCategorySeed();
}
const raw = window.localStorage.getItem(HEADER_CONFIG_STORAGE_KEY);
if (!raw) return getHeaderCategorySeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategorySeed();
}
return normalizeHeaderCategories(parsed);
} catch {
return getHeaderCategorySeed();
}
}
function flattenHeaderTree(
items: HeaderCategoryTreeItem[],
depth = 0,
): Array<{
id: string;
name: string;
type: HeaderCategoryItem["type"];
depth: number;
}> {
return items.flatMap((item) => [
{ id: item.id, name: item.name, type: item.type, depth },
...flattenHeaderTree(item.children, depth + 1),
]);
}
function isCategoryCompatible(
headerType: HeaderCategoryItem["type"],
postType: AdminNewsType | "",
) {
if (!postType) return true;
if (headerType === "news") return postType !== "baiviettrang";
if (headerType === "page") return postType === "baiviettrang";
return true;
}
function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
return {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
};
}
function FormSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-5 shadow-sm">
<div className="mb-4">
<h2 className="text-base font-semibold text-[#063e8e]">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-gray-700">{description}</p>
) : null}
</div>
{children}
</div>
);
}
export function AdminNewsForm({
newsId,
presetHeaderCategoryId,
lockedType,
returnPath,
}: AdminNewsFormProps) {
const router = useRouter();
const isCreate = !newsId || newsId === "new";
const backPath = returnPath || "/admin/news";
const isHeaderCategoryLocked = Boolean(presetHeaderCategoryId);
const isTypeLocked = Boolean(lockedType);
const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const [form, setForm] = React.useState<AdminNewsFormValues | null>(null);
const [pickerOpen, setPickerOpen] = React.useState(false);
React.useEffect(() => {
const nextNewsItems = readAdminNewsItems();
const nextHeaderItems = readHeaderConfig();
const currentItem = nextNewsItems.find((item) => item.id === newsId) ?? null;
setItems(nextNewsItems);
setHeaderItems(nextHeaderItems);
if (isCreate) {
const now = new Date().toISOString();
setForm({
...cloneAdminNewsFormValues(),
type: lockedType ?? "tintuc",
header_category_id: presetHeaderCategoryId ?? "",
created_at: now,
updated_at: now,
});
return;
}
if (!currentItem) {
setForm(null);
return;
}
if (
presetHeaderCategoryId &&
currentItem.header_category_id !== presetHeaderCategoryId
) {
setForm(null);
return;
}
if (lockedType && currentItem.type !== lockedType) {
setForm(null);
return;
}
setForm(cloneAdminNewsFormValues(currentItem));
}, [isCreate, lockedType, newsId, presetHeaderCategoryId]);
const headerOptions = React.useMemo(() => {
return flattenHeaderTree(buildHeaderCategoryTree(headerItems)).filter(
(item) => item.type === "news" || item.type === "page",
);
}, [headerItems]);
const selectedHeaderCategory = React.useMemo(() => {
return headerItems.find((item) => item.id === form?.header_category_id) ?? null;
}, [form?.header_category_id, headerItems]);
const availableSearchTags = React.useMemo(() => {
if (!selectedHeaderCategory || selectedHeaderCategory.type !== "news") return [];
return selectedHeaderCategory.tagsearch_values ?? [];
}, [selectedHeaderCategory]);
const articlePageAlreadyUsed = React.useMemo(() => {
if (!form?.header_category_id || form.type !== "baiviettrang") return false;
return items.some(
(item) =>
item.header_category_id === form.header_category_id &&
item.type === "baiviettrang" &&
item.id !== newsId,
);
}, [form?.header_category_id, form?.type, items, newsId]);
const handleField = <K extends keyof AdminNewsFormValues>(
key: K,
value: AdminNewsFormValues[K],
) => {
setForm((current) => {
if (!current) return current;
return { ...current, [key]: value };
});
};
const handleTitleChange = (value: string) => {
setForm((current) => {
if (!current) return current;
return {
...current,
title: value,
slug: slugifyAdminNews(value),
};
});
};
const handleTypeChange = (value: string) => {
const nextType = resolveAdminNewsType(value) ?? "tintuc";
setForm((current) => {
if (!current) return current;
const compatibleHeader = headerOptions.find(
(option) =>
option.id === current.header_category_id &&
isCategoryCompatible(option.type, nextType),
);
return {
...current,
type: nextType,
header_category_id: compatibleHeader ? current.header_category_id : "",
category_ids: nextType === "baiviettrang" ? [] : current.category_ids,
tagsearch_values: nextType === "baiviettrang" ? [] : current.tagsearch_values,
is_featured: nextType === "tintuc" ? current.is_featured : false,
};
});
};
const handleHeaderCategoryChange = (value: string) => {
const nextCategory = headerItems.find((item) => item.id === value) ?? null;
const nextSearchTags =
nextCategory?.type === "news" ? nextCategory.tagsearch_values ?? [] : [];
setForm((current) => {
if (!current) return current;
return {
...current,
header_category_id: value,
tagsearch_values: current.tagsearch_values.filter((item) =>
nextSearchTags.includes(item),
),
};
});
};
const handleToggleSearchTag = (value: string, checked: boolean) => {
setForm((current) => {
if (!current) return current;
return {
...current,
tagsearch_values: checked
? [...current.tagsearch_values, value]
: current.tagsearch_values.filter((item) => item !== value),
};
});
};
const handleThumbnailSelect = (item: AdminMediaItem) => {
handleField("thumbnail", toImageRef(item));
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form) return;
if (!form.title.trim()) {
toast.error("Tiêu đề bài viết là bắt buộc");
return;
}
if (!form.slug.trim()) {
toast.error("Slug bài viết là bắt buộc");
return;
}
if (!form.type) {
toast.error("Vui lòng chọn loại bài viết");
return;
}
if (!form.header_category_id) {
toast.error("Vui lòng chọn danh mục hiển thị");
return;
}
if (articlePageAlreadyUsed) {
toast.error("Danh mục bài viết trang chỉ được tạo 1 bài viết");
return;
}
const now = new Date().toISOString();
const currentId =
items.find((item) => item.id === newsId)?.id ?? createAdminNewsId();
const payload: AdminNewsItem = {
id: isCreate ? currentId : (newsId as string),
title: form.title.trim(),
slug: slugifyAdminNews(form.slug.trim()),
summary: form.summary,
type: form.type,
header_category_id: form.header_category_id,
category_ids: form.type === "baiviettrang" ? [] : form.category_ids,
tagsearch_values:
form.type === "baiviettrang"
? []
: form.tagsearch_values.filter((item) => availableSearchTags.includes(item)),
is_featured: form.type === "tintuc" ? form.is_featured : false,
thumbnail: form.thumbnail,
is_hidden: form.is_hidden,
created_at: form.created_at || now,
updated_at: now,
published_at: form.published_at,
expired_at: form.expired_at,
started_at: form.started_at,
ended_at: form.ended_at,
registration_deadline: form.registration_deadline,
location: form.location.trim(),
participation_fee: form.participation_fee.trim(),
post_content: form.post_content.map((section, index) => ({
...section,
position: index + 1,
})),
};
const nextItems = isCreate
? [payload, ...items]
: items.map((item) => (item.id === payload.id ? payload : item));
persistAdminNewsItems(nextItems);
setItems(nextItems);
toast.success(isCreate ? "Đã tạo bài viết" : "Đã cập nhật bài viết");
router.push(backPath);
};
if (form === null && !isCreate) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center">
<p className="text-lg font-semibold text-black">Không tìm thấy bài viết</p>
<p className="mt-2 text-sm text-gray-700">
{presetHeaderCategoryId
? "Bài viết bạn muốn chỉnh sửa không tồn tại hoặc không thuộc danh mục hiện tại."
: "Bài viết bạn muốn chỉnh sửa không tồn tại trong dữ liệu hiện tại."}
</p>
<Button
asChild
className="mt-5 bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Link href={backPath}>Quay lại danh sách</Link>
</Button>
</div>
);
}
if (!form) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center text-sm text-gray-700">
Đang tải dữ liệu...
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="icon"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={backPath}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<FormSection title={isCreate ? "Tạo bài viết" : "Chỉnh sửa bài viết"}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày tạo</Label>
<Input
value={
form.created_at
? dayjs(form.created_at).format("DD/MM/YYYY HH:mm")
: ""
}
readOnly
className={readOnlyFieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày cập nhật</Label>
<Input
value={
form.updated_at
? dayjs(form.updated_at).format("DD/MM/YYYY HH:mm")
: ""
}
readOnly
className={readOnlyFieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">
Tiêu đề <span className="text-red-600">*</span>
</Label>
<Input
value={form.title}
onChange={(event) => handleTitleChange(event.target.value)}
placeholder="Nhập tiêu đề bài viết"
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">
Slug <span className="text-red-600">*</span>
</Label>
<Input
value={form.slug}
onChange={(event) => handleField("slug", event.target.value)}
placeholder="slug-bai-viet"
className={fieldClassName}
/>
</div>
</div>
</FormSection>
<FormSection title="Thể loại, hình ảnh và hiển thị">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[300px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<div className="space-y-3">
<div>
<Label className="block text-gray-700">Hình ảnh đại diện</Label>
</div>
<div className="relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white">
<div className="relative aspect-[16/11]">
{form.thumbnail ? (
<SafeNextImage
src={form.thumbnail.url}
alt={form.thumbnail.alt || form.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-700">
Chưa chọn hình đại diện
</div>
)}
</div>
{form.thumbnail ? (
<button
type="button"
onClick={() => handleField("thumbnail", null)}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-gray-700 shadow-sm transition hover:text-red-600"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button
type="button"
variant="outline"
onClick={() => setPickerOpen(true)}
className="w-full border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Upload className="mr-2 h-4 w-4" />
{form.thumbnail ? "Đổi hình đại diện" : "Chọn hình đại diện"}
</Button>
</div>
</div>
<div className="rounded-xl border border-[#063e8e]/15 bg-white p-4">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">
Loại bài viết <span className="text-red-600">*</span>
</Label>
<Select
value={form.type}
onValueChange={handleTypeChange}
disabled={isTypeLocked}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn loại bài viết" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{ADMIN_NEWS_TYPE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={selectItemClassName}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">
Danh mục hiển thị
</Label>
<Select
value={form.header_category_id}
onValueChange={handleHeaderCategoryChange}
disabled={isHeaderCategoryLocked}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn danh mục hiển thị" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{headerOptions
.filter((option) => isCategoryCompatible(option.type, form.type))
.map((option) => (
<SelectItem
key={option.id}
value={option.id}
className={selectItemClassName}
>
{`${"-- ".repeat(option.depth)}${option.name}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày xuất bản</Label>
<Input
type="datetime-local"
value={form.published_at}
onChange={(event) =>
handleField("published_at", event.target.value)
}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày hết hạn</Label>
<Input
type="datetime-local"
value={form.expired_at}
onChange={(event) =>
handleField("expired_at", event.target.value)
}
className={fieldClassName}
/>
</div>
</div>
<div className="rounded-xl bg-[#063e8e]/[0.04] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-700">
Trạng thái hiển thị (bài viết sẽ được lưu nhưng không hiển thị trên website)
</p>
<p className="mt-1 text-sm font-medium text-[#063e8e]">
{form.is_hidden ? "Đang ẩn" : "Đang hiển thị"}
</p>
</div>
<Switch
checked={!form.is_hidden}
onCheckedChange={(checked) => handleField("is_hidden", !checked)}
/>
</div>
</div>
{form.type === "tintuc" ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-700">Tin nổi bật</p>
<p className="mt-1 text-sm text-gray-700">
Đánh dấu để ưu tiên hiển thị như một tin nổi bật.
</p>
</div>
<Switch
checked={form.is_featured}
onCheckedChange={(checked) => handleField("is_featured", checked)}
/>
</div>
</div>
) : null}
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
</FormSection>
<FormSection
title="Thông tin sự kiện (tùy chọn)"
description="Nhóm các trường dành cho bài viết có tính chất sự kiện hoặc chương trình."
>
<div className="rounded-xl border border-[#063e8e]/15 p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label>
<Input
type="datetime-local"
value={form.started_at}
onChange={(event) => handleField("started_at", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày kết thúc</Label>
<Input
type="datetime-local"
value={form.ended_at}
onChange={(event) => handleField("ended_at", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">
Ngày hạn đăng ký
</Label>
<Input
type="datetime-local"
value={form.registration_deadline}
onChange={(event) =>
handleField("registration_deadline", event.target.value)
}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Địa điểm</Label>
<Input
value={form.location}
onChange={(event) => handleField("location", event.target.value)}
placeholder="Nhập địa điểm"
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Phí tham dự</Label>
<Input
value={form.participation_fee}
onChange={(event) =>
handleField("participation_fee", event.target.value)
}
placeholder="Ví dụ: Miễn phí hoặc 500.000 VNĐ"
className={fieldClassName}
/>
</div>
</div>
</div>
</FormSection>
<FormSection title="Tóm tắt">
<AdminRichTextEditor
value={form.summary}
onChange={(value) => handleField("summary", value)}
placeholder="Nhập tóm tắt bài viết"
minHeight={180}
/>
</FormSection>
<FormSection
title="Nội dung bài viết"
description="Thêm section văn bản và hình ảnh theo đúng cấu trúc nội dung mong muốn."
>
<AdminPostContentEditor
sections={form.post_content}
onChange={(sections) => handleField("post_content", sections)}
/>
</FormSection>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
type="button"
variant="outline"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={backPath}>Hủy</Link>
</Button>
<Button
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
type="submit"
>
<Save className="mr-2 h-4 w-4" />
{isCreate ? "Lưu bài viết" : "Cập nhật bài viết"}
</Button>
</div>
</form>
<AdminImagePicker
open={pickerOpen}
selectedId={form.thumbnail?.id}
onOpenChange={setPickerOpen}
onSelect={handleThumbnailSelect}
/>
</div>
);
}
"use client";
import * as React from "react";
import { Image as ImageIcon, Plus, Type, Upload, X } from "lucide-react";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type AdminMediaItem,
type AdminNewsContentSection,
createAdminNewsSectionId,
} from "@/mockdata/admin-news";
interface AdminPostContentEditorProps {
sections: AdminNewsContentSection[];
onChange: (sections: AdminNewsContentSection[]) => void;
}
export function AdminPostContentEditor({
sections,
onChange,
}: AdminPostContentEditorProps) {
const [pickerState, setPickerState] = React.useState<{
open: boolean;
sectionId: string | null;
position: number;
selectedId: string | null;
}>({
open: false,
sectionId: null,
position: 0,
selectedId: null,
});
const updateSection = React.useCallback(
(
sectionId: string,
updater: (section: AdminNewsContentSection) => AdminNewsContentSection,
) => {
onChange(sections.map((section) => (section.id === sectionId ? updater(section) : section)));
},
[onChange, sections],
);
const appendSection = (type: "text" | "image") => {
const nextSection: AdminNewsContentSection = {
id: createAdminNewsSectionId(),
type,
position: sections.length + 1,
content: "",
image_columns: 2,
image_rows: 1,
images: [],
};
onChange([...sections, nextSection]);
};
const removeSection = (sectionId: string) => {
const nextSections = sections
.filter((section) => section.id !== sectionId)
.map((section, index) => ({
...section,
position: index + 1,
}));
onChange(nextSections);
};
const updateGrid = (sectionId: string, columns: number, rows: number) => {
updateSection(sectionId, (section) => {
const maxImages = columns * rows;
return {
...section,
image_columns: columns,
image_rows: rows,
images: section.images.slice(0, maxImages).map((image, index) => ({
...image,
position: index + 1,
})),
};
});
};
const handleSelectImage = (item: AdminMediaItem) => {
if (!pickerState.sectionId || pickerState.position <= 0) return;
updateSection(pickerState.sectionId, (section) => {
const nextImages = section.images.filter((image) => image.position !== pickerState.position);
nextImages.push({
position: pickerState.position,
image: {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
},
});
return {
...section,
images: nextImages.sort((left, right) => left.position - right.position),
};
});
setPickerState({
open: false,
sectionId: null,
position: 0,
selectedId: null,
});
};
const handleRemoveImage = (sectionId: string, position: number) => {
updateSection(sectionId, (section) => ({
...section,
images: section.images
.filter((image) => image.position !== position)
.map((image, index) => ({
...image,
position: index + 1,
})),
}));
};
return (
<div className="space-y-5">
{sections.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[#063e8e]/20 bg-white px-6 py-10 text-center">
<p className="text-base font-medium text-black">Chưa có nội dung bài viết</p>
<p className="mt-1 text-sm text-gray-700">
Bắt đầu bằng section văn bản hoặc section hình ảnh.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => appendSection("text")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Type className="mr-2 h-4 w-4" />
Thêm section văn bản
</Button>
<Button
type="button"
variant="outline"
onClick={() => appendSection("image")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<ImageIcon className="mr-2 h-4 w-4" />
Thêm section hình ảnh
</Button>
</div>
</div>
) : null}
{sections.map((section) => {
const maxSlots = section.image_columns * section.image_rows;
return (
<div
key={section.id}
className="rounded-3xl border border-[#063e8e]/15 bg-white shadow-sm"
>
<div className="flex items-center justify-between gap-4 border-b border-[#063e8e]/10 px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#063e8e]/10 text-[#063e8e]">
{section.type === "text" ? (
<Type className="h-4 w-4" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</div>
<div>
<p className="text-sm font-medium text-gray-700">Section {section.position}</p>
<p className="text-base font-semibold text-black">
{section.type === "text" ? "Văn bản" : "Hình ảnh"}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeSection(section.id)}
className="text-gray-700 hover:bg-red-50 hover:text-red-600"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-5 px-5 py-5">
{section.type === "text" ? (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Nội dung</Label>
<AdminRichTextEditor
value={section.content}
onChange={(value) =>
updateSection(section.id, (current) => ({
...current,
content: value,
}))
}
placeholder="Nhập nội dung section văn bản..."
minHeight={240}
/>
</div>
) : (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Số cột ảnh</Label>
<Select
value={String(section.image_columns)}
onValueChange={(value) =>
updateGrid(section.id, Number(value), section.image_rows)
}
>
<SelectTrigger className="border-[#063e8e]/15 text-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 cột</SelectItem>
<SelectItem value="2">2 cột</SelectItem>
<SelectItem value="3">3 cột</SelectItem>
<SelectItem value="4">4 cột</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Số hàng ảnh</Label>
<Select
value={String(section.image_rows)}
onValueChange={(value) =>
updateGrid(section.id, section.image_columns, Number(value))
}
>
<SelectTrigger className="border-[#063e8e]/15 text-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 hàng</SelectItem>
<SelectItem value="2">2 hàng</SelectItem>
<SelectItem value="3">3 hàng</SelectItem>
<SelectItem value="4">4 hàng</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm font-medium text-gray-700">
Hình ảnh ({section.images.length}/{maxSlots})
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position: Math.min(section.images.length + 1, maxSlots),
selectedId: null,
})
}
disabled={section.images.length >= maxSlots}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Plus className="mr-2 h-4 w-4" />
Thêm ảnh
</Button>
</div>
<div
className="grid gap-3 rounded-2xl border border-[#063e8e]/10 bg-[#063e8e]/[0.03] p-4"
style={{
gridTemplateColumns: `repeat(${section.image_columns}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: maxSlots }).map((_, index) => {
const position = index + 1;
const currentImage = section.images.find(
(image) => image.position === position,
);
return (
<div
key={`${section.id}-${position}`}
role="button"
tabIndex={0}
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
})
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
});
}
}}
className="group relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white text-center transition hover:border-[#063e8e]/40"
>
{currentImage ? (
<>
<SafeNextImage
src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name}
fill
className="object-cover"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveImage(section.id, position);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-700 shadow-sm transition hover:text-red-600"
>
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<div className="px-3 text-center">
<Upload className="mx-auto mb-2 h-5 w-5 text-[#063e8e]" />
<p className="text-xs font-medium text-gray-700">
Chọn ảnh {position}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => appendSection("text")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Type className="mr-2 h-4 w-4" />
Thêm section văn bản
</Button>
<Button
type="button"
variant="outline"
onClick={() => appendSection("image")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<ImageIcon className="mr-2 h-4 w-4" />
Thêm section hình ảnh
</Button>
</div>
<AdminImagePicker
open={pickerState.open}
selectedId={pickerState.selectedId}
onOpenChange={(open) =>
setPickerState((current) => ({
...current,
open,
...(open ? {} : { sectionId: null, position: 0, selectedId: null }),
}))
}
onSelect={handleSelectImage}
/>
</div>
);
}
"use client";
import dynamic from "next/dynamic";
import { useMemo, useRef } from "react";
import type { JoditEditorProps } from "jodit-react";
const JoditEditor = dynamic(() => import("jodit-react"), {
ssr: false,
loading: () => (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-[#063e8e]/15 bg-white text-sm text-gray-500">
Đang tải trình soạn thảo...
</div>
),
});
interface AdminRichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: number;
readOnly?: boolean;
}
export function AdminRichTextEditor({
value,
onChange,
placeholder = "Nhập nội dung...",
className = "",
minHeight = 260,
readOnly = false,
}: AdminRichTextEditorProps) {
const editor = useRef(null);
const config: JoditEditorProps["config"] = useMemo(
() => ({
readonly: readOnly,
placeholder,
minHeight,
language: "vi",
toolbarButtonSize: "middle",
uploader: {
insertImageAsBase64URI: true,
},
buttons: [
"bold",
"italic",
"underline",
"strikethrough",
"|",
"ul",
"ol",
"|",
"outdent",
"indent",
"|",
"font",
"fontsize",
"brush",
"paragraph",
"|",
"image",
"table",
"link",
"|",
"align",
"undo",
"redo",
"|",
"hr",
"eraser",
"copyformat",
"|",
"symbol",
"fullsize",
],
buttonsXS: [
"bold",
"italic",
"|",
"ul",
"ol",
"|",
"image",
"link",
"table",
"|",
"align",
"|",
"undo",
"redo",
"|",
"dots",
],
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_as_html",
enter: "p",
showPlaceholder: false,
}),
[minHeight, placeholder, readOnly],
);
return (
<div className={className}>
<style jsx global>{`
.admin-rich-text-editor .jodit-container {
border-radius: 1rem;
border: 1px solid rgba(6, 62, 142, 0.15);
overflow: hidden;
background: #ffffff;
}
.admin-rich-text-editor .jodit-toolbar__box {
border-bottom: 1px solid rgba(6, 62, 142, 0.12);
background: rgba(6, 62, 142, 0.04);
padding: 10px;
}
.admin-rich-text-editor .jodit-workplace {
min-height: ${minHeight}px;
}
.admin-rich-text-editor .jodit-wysiwyg {
min-height: ${minHeight}px;
padding: 16px 18px;
color: #111827;
font-size: 14px;
line-height: 1.8;
}
.admin-rich-text-editor .jodit-wysiwyg p {
margin-bottom: 1em;
}
.admin-rich-text-editor .jodit-wysiwyg img {
max-width: 100%;
height: auto;
border-radius: 0.75rem;
}
.admin-rich-text-editor .jodit-placeholder {
color: #374151 !important;
}
`}</style>
<div className="admin-rich-text-editor">
<JoditEditor
ref={editor}
value={value}
config={config}
onBlur={(nextContent) => onChange(nextContent)}
onChange={() => undefined}
/>
</div>
</div>
);
}
"use client";
import Image, { type ImageProps } from "next/image";
import * as React from "react";
interface SafeNextImageProps extends Omit<ImageProps, "src"> {
src?: string | null;
fallbackSrc?: string;
}
export function SafeNextImage({
src,
alt,
fallbackSrc = "/img-error.png",
...props
}: SafeNextImageProps) {
const [currentSrc, setCurrentSrc] = React.useState(src || fallbackSrc);
const [hasFailed, setHasFailed] = React.useState(false);
React.useEffect(() => {
setCurrentSrc(src || fallbackSrc);
setHasFailed(false);
}, [fallbackSrc, src]);
return (
<Image
{...props}
src={currentSrc}
alt={alt}
onError={() => {
if (hasFailed || currentSrc === fallbackSrc) return;
setHasFailed(true);
setCurrentSrc(fallbackSrc);
}}
unoptimized
/>
);
}
...@@ -28,17 +28,17 @@ type NavItem = { ...@@ -28,17 +28,17 @@ type NavItem = {
}; };
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: BarChart3 }, // { 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ý hội viên', href: '/admin/members', icon: Users },
{ name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 }, // { name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 },
{ name: 'Email thông tin', href: '/admin/emails', icon: Mail }, // { name: 'Email thông tin', href: '/admin/emails', icon: Mail },
{ // {
name: 'Thiết lập', // name: 'Thiết lập',
icon: Settings, // icon: Settings,
children: [{ name: 'Thông tin website', href: '/admin/website-config' }], // children: [{ name: 'Thông tin website', href: '/admin/website-config' }],
}, // },
]; ];
export function AdminSidebar() { export function AdminSidebar() {
......
"use client";
export const ADMIN_NEWS_STORAGE_KEY = "vcci-news.admin-news.data.v3";
export const ADMIN_MEDIA_STORAGE_KEY = "vcci-news.admin-media-library.data.v1";
export const ADMIN_NEWS_TYPE_OPTIONS = [
{ value: "tintuc", label: "Tin tức" },
{ value: "baiviettrang", label: "Bài viết trang" },
] as const;
export type AdminNewsType = (typeof ADMIN_NEWS_TYPE_OPTIONS)[number]["value"];
export const ADMIN_NEWS_TYPE_LABELS: Record<AdminNewsType, string> =
ADMIN_NEWS_TYPE_OPTIONS.reduce(
(result, option) => {
result[option.value] = option.label;
return result;
},
{} as Record<AdminNewsType, string>,
);
export interface AdminMediaItem {
id: string;
name: string;
alt: string;
url: string;
mime: string;
size: number;
created_at: string;
updated_at: string;
source: "seed" | "upload";
}
export interface AdminNewsImageRef {
id: string;
name: string;
alt: string;
url: string;
}
export interface AdminNewsContentImage {
position: number;
image: AdminNewsImageRef;
}
export interface AdminNewsContentSection {
id: string;
type: "text" | "image";
position: number;
content: string;
image_columns: number;
image_rows: number;
images: AdminNewsContentImage[];
}
export interface AdminNewsItem {
id: string;
title: string;
slug: string;
summary: string;
type: AdminNewsType;
header_category_id: string;
category_ids: string[];
tagsearch_values: string[];
is_featured: boolean;
thumbnail: AdminNewsImageRef | null;
is_hidden: boolean;
created_at: string;
updated_at: string;
published_at: string;
expired_at: string;
started_at: string;
ended_at: string;
registration_deadline: string;
location: string;
participation_fee: string;
post_content: AdminNewsContentSection[];
}
export interface AdminNewsFormValues {
title: string;
slug: string;
summary: string;
type: AdminNewsType | "";
header_category_id: string;
category_ids: string[];
tagsearch_values: string[];
is_featured: boolean;
thumbnail: AdminNewsImageRef | null;
is_hidden: boolean;
created_at: string;
updated_at: string;
published_at: string;
expired_at: string;
started_at: string;
ended_at: string;
registration_deadline: string;
location: string;
participation_fee: string;
post_content: AdminNewsContentSection[];
}
export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = {
title: "",
slug: "",
summary: "",
type: "tintuc",
header_category_id: "",
category_ids: [],
tagsearch_values: [],
is_featured: false,
thumbnail: null,
is_hidden: false,
created_at: "",
updated_at: "",
published_at: "",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "",
participation_fee: "",
post_content: [],
};
const mediaSeed: AdminMediaItem[] = [
{
id: "media-banner",
name: "Banner VCCI News",
alt: "Banner VCCI News",
url: "/banner.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-01T08:00:00.000Z",
updated_at: "2026-05-01T08:00:00.000Z",
source: "seed",
},
{
id: "media-thumbnail",
name: "Thumbnail mặc định",
alt: "Thumbnail mặc định",
url: "/thumbnail.png",
mime: "image/png",
size: 0,
created_at: "2026-05-01T08:10:00.000Z",
updated_at: "2026-05-01T08:10:00.000Z",
source: "seed",
},
{
id: "media-home-01",
name: "Hoạt động hội viên",
alt: "Hoạt động hội viên",
url: "/home/20-2048x1365.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-02T07:30:00.000Z",
updated_at: "2026-05-02T07:30:00.000Z",
source: "seed",
},
{
id: "media-home-02",
name: "Banner sự kiện",
alt: "Banner sự kiện",
url: "/home/eCarAid_web_banner_600x400.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-03T09:15:00.000Z",
updated_at: "2026-05-03T09:15:00.000Z",
source: "seed",
},
];
function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
return {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
};
}
const newsSeed: AdminNewsItem[] = [
{
id: "admin-news-01",
title: "VCCI thúc đẩy kết nối doanh nghiệp hội viên khu vực phía Nam",
slug: "vcci-thuc-day-ket-noi-doanh-nghiep-hoi-vien-khu-vuc-phia-nam",
summary:
"<p>Bản tin tổng hợp các hoạt động kết nối doanh nghiệp, mở rộng thị trường và nâng cao năng lực quản trị cho hội viên VCCI.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Doanh nghiệp hội viên", "Chuyển đổi số"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
created_at: "2026-05-08T09:00:00.000Z",
updated_at: "2026-05-10T09:30:00.000Z",
published_at: "2026-05-08T09:00",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
post_content: [
{
id: "section-admin-news-01-a",
type: "text",
position: 1,
content:
"<p>Chương trình tập trung vào các giải pháp mở rộng mạng lưới doanh nghiệp hội viên, đồng thời hỗ trợ các đơn vị tiếp cận cơ hội hợp tác mới trong năm 2026.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
{
id: "section-admin-news-01-b",
type: "image",
position: 2,
content: "",
image_columns: 2,
image_rows: 1,
images: [
{ position: 1, image: toImageRef(mediaSeed[2]) },
{ position: 2, image: toImageRef(mediaSeed[3]) },
],
},
],
},
{
id: "admin-news-02",
title: "Lịch hội thảo chuyển đổi số dành cho hội viên tháng 5",
slug: "lich-hoi-thao-chuyen-doi-so-danh-cho-hoi-vien-thang-5",
summary:
"<p>Lịch hội thảo cập nhật những chương trình đào tạo, chia sẻ chuyên đề và kết nối nguồn lực hỗ trợ doanh nghiệp.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Hội thảo", "Đăng ký"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
created_at: "2026-05-09T08:30:00.000Z",
updated_at: "2026-05-11T11:00:00.000Z",
published_at: "2026-05-09T08:30",
expired_at: "2026-05-31T18:00",
started_at: "2026-05-20T08:00",
ended_at: "2026-05-20T17:00",
registration_deadline: "2026-05-18T17:00",
location: "Trung tâm Hội nghị VCCI",
participation_fee: "500.000 VNĐ",
post_content: [
{
id: "section-admin-news-02-a",
type: "text",
position: 1,
content:
"<p>Nội dung chuỗi hội thảo bao gồm chuyển đổi số, quản trị dữ liệu, truyền thông nội bộ và ứng dụng AI trong hoạt động doanh nghiệp.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
],
},
{
id: "admin-news-03",
title: "Giới thiệu vai trò của VCCI News trong hệ sinh thái nội dung số",
slug: "gioi-thieu-vai-tro-cua-vcci-news-trong-he-sinh-thai-noi-dung-so",
summary:
"<p>Bài viết trang giới thiệu định hướng phát triển nội dung, cấu trúc quản trị và trải nghiệm người dùng trên website.</p>",
type: "baiviettrang",
header_category_id: "intro-about",
category_ids: [],
tagsearch_values: [],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
created_at: "2026-05-06T10:00:00.000Z",
updated_at: "2026-05-10T16:45:00.000Z",
published_at: "2026-05-06T10:00",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "",
participation_fee: "",
post_content: [
{
id: "section-admin-news-03-a",
type: "text",
position: 1,
content:
"<p>VCCI News được định hướng là trung tâm cập nhật thông tin, chuyên đề và hoạt động hội viên trên cùng một nền tảng nội dung thống nhất.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
{
id: "section-admin-news-03-b",
type: "image",
position: 2,
content: "",
image_columns: 1,
image_rows: 1,
images: [{ position: 1, image: toImageRef(mediaSeed[0]) }],
},
],
},
];
export function slugifyAdminNews(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
export function resolveAdminNewsType(value?: string | null): AdminNewsType | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
const compact = normalized.replace(/[\s_-]+/g, "");
const direct = ADMIN_NEWS_TYPE_OPTIONS.find(
(option) => option.value === normalized || option.value === compact,
);
if (direct) return direct.value;
const aliases: Record<string, AdminNewsType> = {
news: "tintuc",
"tin tuc": "tintuc",
"tin tức": "tintuc",
pagepost: "baiviettrang",
"bai viet trang": "baiviettrang",
"bài viết trang": "baiviettrang",
};
return aliases[normalized] || aliases[compact];
}
export function createAdminNewsId() {
return `admin-news-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function createAdminMediaId() {
return `admin-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function createAdminNewsSectionId() {
return `section-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNewsFormValues {
if (!item) {
return {
...EMPTY_ADMIN_NEWS_FORM,
category_ids: [],
tagsearch_values: [],
post_content: [],
};
}
return {
title: item.title,
slug: item.slug,
summary: item.summary,
type: item.type,
header_category_id: item.header_category_id,
category_ids: [...item.category_ids],
tagsearch_values: [...(item.tagsearch_values ?? [])],
is_featured: item.is_featured ?? false,
thumbnail: item.thumbnail ? { ...item.thumbnail } : null,
is_hidden: item.is_hidden,
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
expired_at: item.expired_at,
started_at: item.started_at,
ended_at: item.ended_at,
registration_deadline: item.registration_deadline,
location: item.location,
participation_fee: item.participation_fee,
post_content: item.post_content.map((section) => ({
...section,
images: section.images.map((image) => ({
...image,
image: { ...image.image },
})),
})),
};
}
export function normalizeAdminMediaItems(items: AdminMediaItem[]) {
return [...items].sort((left, right) => {
return (
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() ||
right.name.localeCompare(left.name, "vi")
);
});
}
export function normalizeAdminNewsItems(items: AdminNewsItem[]) {
return [...items]
.map((item) => ({
...item,
category_ids: Array.isArray(item.category_ids) ? item.category_ids : [],
tagsearch_values: Array.isArray(item.tagsearch_values)
? item.tagsearch_values.filter(Boolean)
: [],
is_featured: item.type === "tintuc" ? Boolean(item.is_featured) : false,
}))
.sort((left, right) => {
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime || right.updated_at.localeCompare(left.updated_at);
});
}
export function getAdminMediaSeed() {
return normalizeAdminMediaItems(mediaSeed);
}
export function getAdminNewsSeed() {
return normalizeAdminNewsItems(newsSeed);
}
export function readAdminMediaItems() {
if (typeof window === "undefined") return getAdminMediaSeed();
const raw = window.localStorage.getItem(ADMIN_MEDIA_STORAGE_KEY);
if (!raw) return getAdminMediaSeed();
try {
const parsed = JSON.parse(raw) as AdminMediaItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getAdminMediaSeed();
}
return normalizeAdminMediaItems(parsed);
} catch {
return getAdminMediaSeed();
}
}
export function readAdminNewsItems() {
if (typeof window === "undefined") return getAdminNewsSeed();
const raw = window.localStorage.getItem(ADMIN_NEWS_STORAGE_KEY);
if (!raw) return getAdminNewsSeed();
try {
const parsed = JSON.parse(raw) as AdminNewsItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getAdminNewsSeed();
}
return normalizeAdminNewsItems(parsed);
} catch {
return getAdminNewsSeed();
}
}
export function persistAdminMediaItems(items: AdminMediaItem[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
ADMIN_MEDIA_STORAGE_KEY,
JSON.stringify(normalizeAdminMediaItems(items)),
);
}
export function persistAdminNewsItems(items: AdminNewsItem[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
ADMIN_NEWS_STORAGE_KEY,
JSON.stringify(normalizeAdminNewsItems(items)),
);
}
"use client"; "use client";
export type HeaderCategoryType = "category" | "page" | "news" | "image"; export type HeaderCategoryType = "category" | "page" | "news";
export interface HeaderCategoryItem { export interface HeaderCategoryItem {
id: string; id: string;
...@@ -13,6 +13,7 @@ export interface HeaderCategoryItem { ...@@ -13,6 +13,7 @@ export interface HeaderCategoryItem {
parent_id: string | null; parent_id: string | null;
level: number; level: number;
category_ids: string[]; category_ids: string[];
tagsearch_values: string[];
description?: string; description?: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
...@@ -29,6 +30,29 @@ export interface HeaderArticleCategoryOption { ...@@ -29,6 +30,29 @@ export interface HeaderArticleCategoryOption {
export const HEADER_CONFIG_STORAGE_KEY = "vcci-news.header-config.data.v1"; export const HEADER_CONFIG_STORAGE_KEY = "vcci-news.header-config.data.v1";
const DEFAULT_HEADER_CATEGORY_SEARCH_TAGS: Record<string, string[]> = {
"activity-news": [
"Doanh nghiệp hội viên",
"Xúc tiến thương mại",
"Chuyển đổi số",
"Kết nối giao thương",
"Bản tin nổi bật",
],
"activity-events": [
"Hội thảo",
"Đăng ký",
"Sự kiện nổi bật",
"Lịch sự kiện",
"Mời tham dự",
],
"library-highlight": [
"Album ảnh",
"Thư viện số",
"Khoảnh khắc nổi bật",
"Hình ảnh sự kiện",
],
};
export const headerCategorySeed: HeaderCategoryItem[] = [ export const headerCategorySeed: HeaderCategoryItem[] = [
{ {
id: "root-home", id: "root-home",
...@@ -41,6 +65,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -41,6 +65,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null, parent_id: null,
level: 1, level: 1,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Trang gốc của website", description: "Trang gốc của website",
}, },
{ {
...@@ -54,6 +79,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -54,6 +79,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null, parent_id: null,
level: 1, level: 1,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Nhóm nội dung giới thiệu", description: "Nhóm nội dung giới thiệu",
}, },
{ {
...@@ -67,6 +93,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -67,6 +93,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "intro", parent_id: "intro",
level: 2, level: 2,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Trang nội dung giới thiệu hệ thống", description: "Trang nội dung giới thiệu hệ thống",
}, },
{ {
...@@ -80,6 +107,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -80,6 +107,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "intro", parent_id: "intro",
level: 2, level: 2,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Trang thông tin cơ cấu tổ chức", description: "Trang thông tin cơ cấu tổ chức",
}, },
{ {
...@@ -93,6 +121,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -93,6 +121,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null, parent_id: null,
level: 1, level: 1,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Nhóm nội dung tin tức và hoạt động", description: "Nhóm nội dung tin tức và hoạt động",
}, },
{ {
...@@ -106,6 +135,11 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -106,6 +135,11 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "activity", parent_id: "activity",
level: 2, level: 2,
category_ids: ["cat-news", "cat-activity"], category_ids: ["cat-news", "cat-activity"],
tagsearch_values: [
"Doanh nghiệp hội viên",
"Xúc tiến thương mại",
"Chuyển đổi số",
],
description: "Danh mục tin tức tổng hợp", description: "Danh mục tin tức tổng hợp",
}, },
{ {
...@@ -119,6 +153,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -119,6 +153,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "activity", parent_id: "activity",
level: 2, level: 2,
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Hội thảo", "Đăng ký", "Sự kiện nổi bật"],
description: "Danh mục sự kiện", description: "Danh mục sự kiện",
}, },
{ {
...@@ -132,6 +167,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -132,6 +167,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null, parent_id: null,
level: 1, level: 1,
category_ids: [], category_ids: [],
tagsearch_values: [],
description: "Khu vực ảnh và album", description: "Khu vực ảnh và album",
}, },
{ {
...@@ -140,11 +176,12 @@ export const headerCategorySeed: HeaderCategoryItem[] = [ ...@@ -140,11 +176,12 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
slug: "album-noi-bat", slug: "album-noi-bat",
static_link: "/thu-vien-anh/album-noi-bat", static_link: "/thu-vien-anh/album-noi-bat",
sort_order: 1, sort_order: 1,
type: "image", type: "news",
is_article: false, is_article: true,
parent_id: "library", parent_id: "library",
level: 2, level: 2,
category_ids: [], category_ids: [],
tagsearch_values: ["Album ảnh", "Thư viện số"],
description: "Album ảnh nổi bật", description: "Album ảnh nổi bật",
}, },
]; ];
...@@ -154,125 +191,10 @@ export const headerArticleCategoryOptions: HeaderArticleCategoryOption[] = [ ...@@ -154,125 +191,10 @@ export const headerArticleCategoryOptions: HeaderArticleCategoryOption[] = [
{ id: "cat-activity", name: "Hoạt động VCCI" }, { id: "cat-activity", name: "Hoạt động VCCI" },
{ id: "cat-event", name: "Sự kiện" }, { id: "cat-event", name: "Sự kiện" },
{ id: "cat-policy", name: "Chính sách" }, { id: "cat-policy", name: "Chính sách" },
{ id: "cat-gallery", name: "Ảnh nổi bật" },
]; ];
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) { export function toSlug(value: string) {
return normalizeVietnameseText(value) return value
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d") .replace(/đ/g, "d")
...@@ -284,6 +206,27 @@ export function toSlug(value: string) { ...@@ -284,6 +206,27 @@ export function toSlug(value: string) {
.replace(/-+/g, "-"); .replace(/-+/g, "-");
} }
function normalizeTagsearchValues(values?: string[]) {
if (!Array.isArray(values)) return [];
const seen = new Set<string>();
return values
.map((value) => value.trim())
.filter((value) => {
if (!value) return false;
const key = value.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function getDefaultTagsearchValues(itemId: string) {
return DEFAULT_HEADER_CATEGORY_SEARCH_TAGS[itemId] ?? [];
}
function buildStaticLink( function buildStaticLink(
item: Pick<HeaderCategoryItem, "slug" | "parent_id">, item: Pick<HeaderCategoryItem, "slug" | "parent_id">,
items: HeaderCategoryItem[], items: HeaderCategoryItem[],
...@@ -296,9 +239,11 @@ function buildStaticLink( ...@@ -296,9 +239,11 @@ function buildStaticLink(
while (currentParentId) { while (currentParentId) {
const parent = items.find((entry) => entry.id === currentParentId); const parent = items.find((entry) => entry.id === currentParentId);
if (!parent) break; if (!parent) break;
if (parent.slug.trim()) { if (parent.slug.trim()) {
segments.unshift(parent.slug.trim()); segments.unshift(parent.slug.trim());
} }
currentParentId = parent.parent_id; currentParentId = parent.parent_id;
} }
...@@ -320,7 +265,19 @@ function assignLevel(item: HeaderCategoryItem, items: HeaderCategoryItem[]) { ...@@ -320,7 +265,19 @@ function assignLevel(item: HeaderCategoryItem, items: HeaderCategoryItem[]) {
} }
export function normalizeHeaderCategories(items: HeaderCategoryItem[]) { export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
const sanitizedItems = items.map((item) => normalizeHeaderCategoryText(item)); const sanitizedItems = items.map((item) => {
const normalizedType =
(item.type as unknown as string) === "image" ? "news" : item.type;
return {
...item,
type: normalizedType as HeaderCategoryType,
is_article: normalizedType === "news",
category_ids: Array.isArray(item.category_ids) ? item.category_ids : [],
tagsearch_values: normalizeTagsearchValues(item.tagsearch_values),
};
});
const parentIds = new Set( const parentIds = new Set(
sanitizedItems sanitizedItems
.filter((item) => item.parent_id) .filter((item) => item.parent_id)
...@@ -333,14 +290,19 @@ export function normalizeHeaderCategories(items: HeaderCategoryItem[]) { ...@@ -333,14 +290,19 @@ export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
if (parentIds.has(next.id)) { if (parentIds.has(next.id)) {
next.type = "category"; next.type = "category";
next.category_ids = []; next.category_ids = [];
next.tagsearch_values = [];
} }
next.level = assignLevel(next, sanitizedItems); next.level = assignLevel(next, sanitizedItems);
next.static_link = next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, sanitizedItems); next.static_link =
next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, sanitizedItems);
next.is_article = next.type === "news"; next.is_article = next.type === "news";
if (next.type !== "news") { if (next.type !== "news") {
next.category_ids = []; next.category_ids = [];
next.tagsearch_values = [];
} else if (next.tagsearch_values.length === 0) {
next.tagsearch_values = getDefaultTagsearchValues(next.id);
} }
return next; return next;
...@@ -389,8 +351,6 @@ export function getHeaderCategoryTypeLabel(type: HeaderCategoryType) { ...@@ -389,8 +351,6 @@ export function getHeaderCategoryTypeLabel(type: HeaderCategoryType) {
return "Bài viết trang"; return "Bài viết trang";
case "news": case "news":
return "Tin tức"; return "Tin tức";
case "image":
return "Ảnh";
default: default:
return type; return type;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment