Merge branch 'master' of github.com:Pregnant-Guild/FE_User_history_web

This commit is contained in:
taDuc
2026-05-14 17:24:46 +07:00
9 changed files with 616 additions and 329 deletions
+164 -61
View File
@@ -29,6 +29,7 @@
"maplibre-gl": "^5.20.2",
"next": "^16.1.6",
"polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0",
"react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1",
@@ -102,7 +103,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1955,7 +1955,6 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
@@ -3027,7 +3026,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz",
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
@@ -3051,7 +3049,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz",
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14.18"
},
@@ -3228,7 +3225,6 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -3671,7 +3667,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -3988,7 +3983,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -4136,7 +4130,6 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4154,7 +4147,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4165,7 +4157,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4240,7 +4231,6 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
@@ -4772,7 +4762,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4828,7 +4817,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz",
"integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@svgdotjs/svg.draggable.js": "^3.0.4",
"@svgdotjs/svg.filter.js": "^3.0.8",
@@ -5240,7 +5228,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5259,7 +5246,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
@@ -5291,7 +5277,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -5376,6 +5361,16 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5660,6 +5655,27 @@
}
}
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5681,7 +5697,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -5699,7 +5714,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -6103,7 +6117,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6289,7 +6302,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6530,10 +6542,18 @@
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT",
"peer": true
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT",
"peer": true
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
@@ -6782,7 +6802,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6970,7 +6989,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -7122,6 +7140,23 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7274,7 +7309,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -7403,7 +7437,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -7589,8 +7622,7 @@
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
@@ -8453,11 +8485,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8628,10 +8676,11 @@
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/parent-module": {
"version": "1.0.1",
@@ -8771,7 +8820,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8936,7 +8984,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -8966,7 +9013,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -9015,7 +9061,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -9084,18 +9129,39 @@
"license": "ISC"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-blot-formatter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/quill-blot-formatter/-/quill-blot-formatter-1.0.5.tgz",
"integrity": "sha512-iVmuEdmMIpvERBnnDfosWul6VAVN6tqQRruUzAEwA9ZbQ/Ef7DTHGZDUR4KklXpxM+z50opFp6m1NhNdN6HJhw==",
"license": "Apache-2.0",
"dependencies": {
"deepmerge": "^2.0.0"
},
"peerDependencies": {
"quill": "^1.3.4"
}
},
"node_modules/quill-blot-formatter/node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"npm": ">=8.2.3"
"node": ">=0.10.0"
}
},
"node_modules/quill-delta": {
@@ -9103,7 +9169,6 @@
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
@@ -9113,12 +9178,33 @@
"node": ">= 12.0.0"
}
},
"node_modules/quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9180,7 +9266,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9226,12 +9311,38 @@
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-quill-new/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/react-quill-new/node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/react-quill-new/node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -9254,8 +9365,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -9313,7 +9423,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
@@ -9556,7 +9665,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -9574,7 +9682,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -10079,8 +10186,7 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -10136,7 +10242,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10314,7 +10419,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10693,7 +10797,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1
View File
@@ -30,6 +30,7 @@
"maplibre-gl": "^5.20.2",
"next": "^16.1.6",
"polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0",
"react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1",
+1 -1
View File
@@ -257,7 +257,7 @@ export default function ProjectDetailsPage() {
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 pb-3">
{[
{
id: "overview",
+102 -105
View File
@@ -131,6 +131,7 @@ export default function ProjectsPage() {
setFormData({ title: "", description: "", project_status: "PRIVATE" });
setImportSnapshot(null);
setImportSnapshotName(null);
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
fetchProjects();
router.push(`/editor/${projectId}`);
} catch (error) {
@@ -199,15 +200,17 @@ export default function ProjectsPage() {
return 0;
});
// Helper format ngày
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return `Updated on ${date.toLocaleDateString("vi-VN", {
if (isNaN(date.getTime())) return "-";
return date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "short",
year: "numeric",
})}`;
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusBadge = (status: string) => {
@@ -228,17 +231,16 @@ export default function ProjectsPage() {
return (
<button
onClick={() => handleSort(column)}
className={`w-20 text-sm font-medium text-left hover:text-blue-500 transition-colors ${
className={`flex items-center gap-1 text-sm font-medium hover:text-blue-500 transition-colors ${
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
}`}
>
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
<span>{label}</span>
{isActive && <span>{sortOrder === "asc" ? "↑" : "↓"}</span>}
</button>
);
};
console.log(projects);
const importLabel = useMemo(() => {
if (!importSnapshotName) return "Chưa chọn JSON snapshot";
return `JSON: ${importSnapshotName}`;
@@ -266,137 +268,134 @@ export default function ProjectsPage() {
{!isLoading && sortedProjects.length > 0 ? (
<div className="max-w-full overflow-x-auto">
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[700px]">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 w-40"></span>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm text-gray-500 dark:text-gray-400 w-20">Sắp xếp:</span>
<SortButton column="title" label="Tên" />
<SortButton column="created_at" label="Ngày tạo" />
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[800px]">
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<div className="flex-1 pr-4">
<SortButton column="title" label="Tên dự án" />
</div>
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Trạng thái</div>
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Thành viên</div>
<div className="w-32 px-4">
<SortButton column="updated_at" label="Cập nhật" />
</div>
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">Thao tác</div>
</div>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{sortedProjects.map((project: any) => (
<div
key={project.id}
className="group flex flex-col p-5 md:flex-row md:items-center justify-between hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
>
<div className="flex-1 pr-4 max-w-full md:max-w-[75%]">
<div
onClick={() => router.push(`/user/projects/${project.id}`)}
className="flex items-center gap-2 mb-2 cursor-pointer hover:underline"
>
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
<div className="flex-1 pr-4 min-w-0">
<div className="items-center gap-3 mb-1.5">
<h3
onClick={() => router.push(`/user/projects/${project.id}`)}
className="font-semibold text-blue-600 dark:text-[#58a6ff] truncate cursor-pointer hover:underline"
>
{project.title}
</h3>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]">
<div className="flex items-center gap-1.5">
{project.user?.avatar_url ? (
<div className="relative w-6 h-6 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
<Image src={project.user.avatar_url} alt="avatar" width={16} height={16} className="rounded-full object-cover" />
) : (
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
<div className="w-4 h-4 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-[8px] font-bold text-gray-500 dark:text-gray-300">
{project.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)}
<span className="truncate max-w-[150px]">{project.user?.display_name || "Unknown"}</span>
</div>
<div className="flex items-center max-w-[250px]">
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
{project.user?.display_name || "Unknown"}
</span>
</div>
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">/</span>
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[300px]">
{project.title}
</h3>
<div className="shrink-0 w-20 flex justify-start">
{getStatusBadge(project.project_status)}
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-[#8b949e] h-5">
<span>{formatDate(project.updated_at)}</span>
</div>
</div>
<div className="flex items-center mt-4 md:mt-0 gap-3 w-[340px] justify-end shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => router.push(`/editor/${project.id}`)}
>
Editor
</Button>
<Button
size="sm"
variant="outline"
disabled={isExportingProjectId === String(project.id)}
onClick={() => handleExportHeadSnapshot(project)}
// title="Export head commit snapshot_json"
>
ExportJSON
</Button>
<Button
size="sm"
variant="outline"
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
>
Editor only wiki
</Button>
<div className="w-48 px-4 shrink-0">
{getStatusBadge(project.project_status)}
</div>
<div className="w-48 px-4 shrink-0">
<div className="flex -space-x-2 overflow-hidden">
{project.members && project.members.length > 0 ? (
<>
{project.members.slice(0, 4).map((m: any, index: number) =>
m.avatar_url ? (
<Image
key={index}
src={m.avatar_url}
alt={m.display_name}
width={32}
height={32}
title={m.display_name}
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
/>
<Image key={index} src={m.avatar_url} alt={m.display_name} width={32} height={32} title={m.display_name} className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white dark:ring-[#0d1117]" />
) : (
<div
key={index}
title={m.display_name}
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
>
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
{m.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
<div key={index} title={m.display_name} className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-[#0d1117]">
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">{m.display_name?.charAt(0)?.toUpperCase() || "U"}</span>
</div>
)
)}
{project.members.length > 4 && (
<div
title="Những người khác"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+{project.members.length - 4}
</span>
<div title="Những người khác" className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white dark:ring-[#0d1117] z-10">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span>
</div>
)}
</>
) : (
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
<span className="text-xs text-gray-400 dark:text-gray-600 italic"></span>
)}
</div>
</div>
<div className="w-32 px-1 shrink-0 text-xs text-gray-500 dark:text-gray-400">
{formatDate(project.updated_at)}
</div>
<div className="w-48 px-4 shrink-0 flex justify-end gap-2">
<div className="relative group/btn1 inline-flex">
<Button
size="sm"
variant="outline"
className="!p-0 w-9 h-9 flex items-center justify-center"
onClick={() => router.push(`/editor/${project.id}`)}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</Button>
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn1:scale-100 group-hover/btn1:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
Editor
</span>
</div>
<div className="relative group/btn2 inline-flex">
<Button
size="sm"
variant="outline"
className="!p-0 w-9 h-9 flex items-center justify-center"
disabled={isExportingProjectId === String(project.id)}
onClick={() => handleExportHeadSnapshot(project)}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</Button>
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn2:scale-100 group-hover/btn2:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
Export JSON
</span>
</div>
<div className="relative group/btn3 inline-flex">
<Button
size="sm"
variant="outline"
className="!p-0 w-9 h-9 flex items-center justify-center"
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</Button>
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn3:scale-100 group-hover/btn3:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
Wiki Editor
</span>
</div>
</div>
</div>
))}
</div>
@@ -414,7 +413,6 @@ export default function ProjectsPage() {
</ComponentCard>
</div>
{/* Modal Tạo Dự án */}
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">Tạo dự án mới</h3>
@@ -484,7 +482,6 @@ export default function ProjectsPage() {
disabled={isSubmitting}
className="bg-gray-900 hover:bg-gray-800 text-white"
onClick={handleCreateProjectWithJson}
// title="Tạo dự án và tạo commit đầu tiên từ JSON snapshot"
>
Tạo với JSON
</Button>
+294 -162
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import "react-quill-new/dist/quill.snow.css";
import { ApiError } from "@/uhm/api/http";
import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis";
import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
@@ -141,18 +141,33 @@ function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html:
return { html: doc.body.innerHTML, toc };
}
function formatDate(value?: string | null): string {
function formatDate(value?: string | null, options?: Intl.DateTimeFormatOptions): string {
const raw = String(value || "").trim();
if (!raw) return "-";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleString();
return d.toLocaleString(
"vi-VN",
options || {
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "long",
year: "numeric",
}
);
}
export default function WikiBySlugClient({ slug }: { slug: string }) {
const [wiki, setWiki] = useState<Wiki | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read");
const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState<Set<string>>(new Set());
const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]);
const [isComparing, setIsComparing] = useState(false);
const [renderHtml, setRenderHtml] = useState<string>("");
const [toc, setToc] = useState<TocItem[]>([]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
@@ -176,6 +191,21 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
const hidePreviewTimerRef = useRef<number | null>(null);
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
const allVersions = useMemo(() => {
if (!wiki) return [];
const current = {
id: wiki.id,
created_at: wiki.updated_at,
content: wiki.content,
isCurrent: true,
};
const history = (wiki.content_sample || []).map(s => ({ ...s, isCurrent: false }));
const uniqueHistory = history.filter(h => h.id !== current.id);
const combined = [current, ...uniqueHistory];
return combined
.filter(v => v.id && v.created_at)
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime());
}, [wiki]);
// Load wiki data by slug.
useEffect(() => {
const value = String(normalizedSlug || "").trim();
@@ -192,6 +222,18 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
setError(null);
try {
const res = await fetchWikiBySlug(value);
let versionContent = res?.content;
try {
if (res?.content_sample?.[0]?.id) {
const contentResp = await getContentByVersionWikiId(res.content_sample[0].id);
if (contentResp?.data?.content) {
versionContent = contentResp.data.content;
}
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
if (disposed) return;
if (!res) {
setWiki(null);
@@ -200,7 +242,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
setToc([]);
return;
}
setWiki(res);
setWiki({ ...res, content: versionContent });
setStatus("ready");
} catch (err) {
if (disposed) return;
@@ -424,107 +466,210 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
};
}, [renderHtml]);
const handleToggleVersionForCompare = (versionId: string) => {
setSelectedVersionsForCompare(prev => {
const next = new Set(prev);
if (next.has(versionId)) {
next.delete(versionId);
} else {
if (next.size >= 3) {
return prev; // Do not allow selecting more than 3
}
next.add(versionId);
}
return next;
});
};
const handleCompareVersions = async () => {
if (selectedVersionsForCompare.size < 1) {
alert("Vui lòng chọn ít nhất 1 phiên bản để so sánh.");
return;
}
setIsComparing(true);
setError(null);
try {
const versionsToFetch = Array.from(selectedVersionsForCompare);
const promises = versionsToFetch.map(async (versionId) => {
const sample = allVersions.find(s => s.id === versionId);
const versionInfo = {
id: versionId,
createdAt: sample?.created_at || 'Unknown date',
title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
};
if (sample?.isCurrent) {
return { ...versionInfo, content: sample.content || '' };
}
const contentResp = await getContentByVersionWikiId(versionId);
return { ...versionInfo, content: contentResp?.data?.content || "" };
});
const results = await Promise.all(promises);
const processedResults = results.map(r => {
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
return { ...r, content: html };
});
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
setViewMode("compare");
} catch (err) {
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
setError(msg);
setViewMode("read");
} finally {
setIsComparing(false);
}
};
return (
<div className="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400">Wiki</div>
<h1 className="mt-1 text-2xl font-bold leading-tight break-words">
{wiki?.title?.trim() || normalizedSlug || "Wiki"}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
<span className="break-all">
<span className="font-semibold">Slug:</span> {normalizedSlug || "-"}
</span>
<span className="break-all">
<span className="font-semibold">ID:</span> {wiki?.id || "-"}
</span>
<span className="break-all">
<span className="font-semibold">Project:</span>{" "}
{wiki?.project_id || "-"}
</span>
<span>
<span className="font-semibold">Created:</span> {formatDate(wiki?.created_at)}
</span>
<span>
<span className="font-semibold">Updated:</span> {formatDate(wiki?.updated_at)}
</span>
</div>
</div>
<div className="min-h-screen bg-[#f8f9fa] text-[#202122] font-sans">
<header className="bg-white border-b border-gray-300 px-6 py-2 flex justify-between items-center">
<div className="text-lg font-bold">GeoHistory Wiki</div>
<Link href="/" className="text-sm text-blue-600 hover:underline">Trang chủ</Link>
</header>
<div className="flex items-center gap-2">
<Link
href="/"
className="h-9 inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-800 px-3 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition"
>
Home
</Link>
</div>
</div>
<div className={viewMode === 'compare' ? '' : 'mx-auto max-w-7xl px-4 sm:px-6 py-6'}>
{status === "loading" && <div className="text-center p-10">Đang tải...</div>}
{status === "error" && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{error}</div>}
{status === "ready" && !wiki && <div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative">Không tìm thấy wiki với slug: <strong>{normalizedSlug}</strong></div>}
{status === "loading" ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6">
<div className="h-5 w-40 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
<div className="mt-3 h-4 w-2/3 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
</div>
) : status === "error" ? (
<div className="rounded-xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 p-6 text-sm text-red-700 dark:text-red-200">
{error || "Failed to load wiki."}
</div>
) : wiki == null ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6 text-sm text-gray-700 dark:text-gray-200">
Không tìm thấy wiki với slug: <span className="font-semibold break-all">{normalizedSlug}</span>
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="lg:sticky lg:top-6 self-start rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Mục lục</div>
{!toc.length ? (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Không tiêu đ (H1/H2/...).</div>
) : (
<nav className="mt-3 max-h-[70vh] overflow-auto pr-1">
<div className="grid gap-1">
{toc.map((t) => {
const pad = Math.max(0, Math.min(5, t.level - 1)) * 10;
const isActive = activeHeadingId === t.id;
return (
<a
key={t.id}
href={`#${t.id}`}
className={`rounded-md px-2 py-1 text-xs leading-5 transition ${
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-white/[0.04]"
}`}
style={{ paddingLeft: 8 + pad }}
title={t.text}
>
{t.text}
</a>
);
})}
</div>
</nav>
)}
<div className="mt-4 border-t border-gray-200 dark:border-gray-800 pt-3">
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all">
Link: {`${typeof window !== "undefined" ? window.location.origin : ""}/wiki/${normalizedSlug}`}
{status === "ready" && wiki && (
<>
<div className={viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6 py-6' : ''}>
<h1 className="text-3xl pb-2 mb-1">
{wiki.title?.trim() || normalizedSlug}
</h1>
{viewMode === 'compare' && (
<div className="mt-4 p-3 border border-gray-300 bg-white rounded-sm text-xs space-y-1">
<div><span className="font-semibold">Slug:</span> {normalizedSlug || "-"}</div>
<div><span className="font-semibold">ID:</span> {wiki.id || "-"}</div>
<div><span className="font-semibold">Dự án:</span> {wiki.project_id || "-"}</div>
<div><span className="font-semibold">Tạo lúc:</span> {formatDate(wiki.created_at)}</div>
<div><span className="font-semibold">Cập nhật:</span> {formatDate(wiki.updated_at)}</div>
</div>
</div>
</aside>
)}
</div>
<main className="min-w-0">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div
ref={contentRootRef}
className="uhm-wiki-view ql-editor text-sm text-gray-900 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
</main>
</div>
<div className={`grid grid-cols-1 ${viewMode === 'compare' ? '' : 'lg:grid-cols-[minmax(0,1fr)_auto] gap-8 items-start'}`}>
<main className={`min-w-0 bg-white ${viewMode === 'compare' ? 'border-y border-gray-300' : 'border border-gray-300 rounded-sm'}`}>
<div className={`flex border-b border-gray-300 text-sm ${viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6' : ''}`}>
<button onClick={() => setViewMode('read')} className={`px-4 py-2 ${viewMode === 'read' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Bài viết</button>
<button onClick={() => setViewMode('history')} className={`px-4 py-2 ${viewMode === 'history' || viewMode === 'compare' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Xem lịch sử</button>
</div>
{viewMode === 'read' && (
<div ref={contentRootRef} className="uhm-wiki-view ql-editor wiki-article" dangerouslySetInnerHTML={{ __html: renderHtml }} />
)}
{viewMode === 'history' && (
<div className="p-4">
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
<div className="flex gap-4 items-center mb-4">
<button onClick={handleCompareVersions} disabled={isComparing || selectedVersionsForCompare.size === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300">
{isComparing ? 'Đang tải...' : `So sánh ${selectedVersionsForCompare.size} phiên bản đã chọn`}
</button>
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-gray-100">
<tr>
<th className="p-2 w-16 text-center">So sánh</th>
<th className="p-2">Ngày cập nhật</th>
<th className="p-2">Ghi chú</th>
</tr>
</thead>
<tbody>
{allVersions.map((v) => {
const isChecked = selectedVersionsForCompare.has(v.id!);
const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3;
return (
<tr key={v.id} className={`border-t ${isDisabled ? "opacity-50" : ""}`}>
<td className="p-2 text-center">
<input
type="checkbox"
onChange={() => handleToggleVersionForCompare(v.id!)}
checked={isChecked}
disabled={isDisabled}
className="h-4 w-4 disabled:cursor-not-allowed"
/>
</td>
<td className="p-2 text-blue-600">{formatDate(v.created_at)}</td>
<td className="p-2">{v.isCurrent && <span className="font-bold">(Phiên bản hiện tại)</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{viewMode === 'compare' && (
<div className="p-4">
<div className="mx-auto max-w-7xl px-4 sm:px-6">
<h2 className="text-xl mb-4 font-normal">So sánh các phiên bản</h2>
</div>
<div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${comparisonData.length >= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}>
{comparisonData.map(version => (
<div key={version.id} className="border rounded-lg overflow-hidden bg-white">
<h3 className="p-2 border-b font-semibold bg-gray-50 text-sm">{version.title}</h3>
<div className="uhm-wiki-view ql-editor wiki-article h-[70vh] overflow-auto" dangerouslySetInnerHTML={{ __html: version.content }} />
</div>
))}
</div>
</div>
)}
</main>
{viewMode !== 'compare' && (
<aside className="hidden lg:block self-start sticky top-6">
{viewMode === 'read' && toc.length > 0 && (
<div className="border border-gray-300 bg-[#f8f9fa] p-3 rounded-sm text-sm mb-6">
<p className="font-bold text-center mb-2">Mục lục</p>
<nav>
<div className="grid gap-1 w-full overflow-auto">
{toc.map((t) => {
const pad = Math.max(0, Math.min(5, t.level - 1)) * 12;
const isActive = activeHeadingId === t.id;
return (
<a key={t.id} href={`#${t.id}`} className={`block py-0.5 text-xs leading-5 transition break-words ${isActive ? "font-bold" : "text-blue-600 hover:underline"}`} style={{ paddingLeft: pad }} title={t.text}>
<span className="mr-1">{t.level}.</span>{t.text}
</a>
);
})}
</div>
</nav>
</div>
)}
<div className="border border-gray-300 bg-white rounded-sm text-xs overflow-hidden">
<table className="w-full">
<tbody>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500 w-1/5">Slug</td>
<td className="px-2 py-2 text-gray-900 break-all">{normalizedSlug || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">ID</td>
<td className="px-2 py-2 text-gray-900">{wiki.id || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">Dự án</td>
<td className="px-2 py-2 text-gray-900">{wiki.project_id || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">Tạo lúc</td>
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.created_at)}</td>
</tr>
<tr>
<td className="pr-1 pl-2 py-2 font-normal text-gray-500">Cập nhật</td>
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.updated_at)}</td>
</tr>
</tbody>
</table>
</div>
</aside>
)}
</div>
</>
)}
</div>
@@ -583,107 +728,94 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
) : null}
<style jsx global>{`
/* Quill view container tweaks: allow page-level scrolling instead of inner scroll. */
.wiki-article {
line-height: 1.6;
font-size: 1em;
padding: 18px 20px;
}
.uhm-wiki-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 20px;
}
/* Improve readability for view mode (Quill resets block margins to 0). */
.uhm-wiki-view.ql-editor p {
.wiki-article p {
margin: 0 0 0.75em;
}
.uhm-wiki-view.ql-editor h1 {
margin: 1.25em 0 0.6em;
font-size: 1.9em;
font-weight: 800;
.wiki-article h1,
.wiki-article h2,
.wiki-article h3,
.wiki-article h4,
.wiki-article h5,
.wiki-article h6 {
font-weight: normal;
margin: 0.8em 0 0.3em;
padding-bottom: 0.1em;
border-bottom: 1px solid #a2a9b1;
scroll-margin-top: 16px;
}
.wiki-article h1 {
font-size: 1.8em;
line-height: 1.2;
}
.uhm-wiki-view.ql-editor h2 {
margin: 1.15em 0 0.55em;
font-size: 1.55em;
font-weight: 800;
.wiki-article h2 {
font-size: 1.5em;
line-height: 1.25;
margin-top: 1.4em;
}
.uhm-wiki-view.ql-editor h3 {
margin: 1.05em 0 0.5em;
.wiki-article h3 {
font-size: 1.25em;
font-weight: 700;
line-height: 1.3;
}
.uhm-wiki-view.ql-editor h4,
.uhm-wiki-view.ql-editor h5,
.uhm-wiki-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
.wiki-article h4,
.wiki-article h5,
.wiki-article h6 {
font-size: 1.05em;
font-weight: 700;
line-height: 1.35;
}
.uhm-wiki-view.ql-editor ul,
.uhm-wiki-view.ql-editor ol {
.wiki-article ul,
.wiki-article ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-view.ql-editor blockquote {
.wiki-article blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.6);
color: rgba(71, 85, 105, 1);
border-left: 3px solid #a2a9b1;
color: #202122;
}
:is(.dark *) .uhm-wiki-view.ql-editor blockquote {
border-left-color: rgba(100, 116, 139, 0.6);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-view.ql-editor pre {
.wiki-article pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1);
border: 1px solid #a2a9b1;
border-radius: 10px;
background: rgba(248, 250, 252, 1);
background: #f8f9fa;
overflow: auto;
font-family: monospace;
}
:is(.dark *) .uhm-wiki-view.ql-editor pre {
border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4);
}
.uhm-wiki-view.ql-editor img {
.wiki-article img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.uhm-wiki-view.ql-editor h1,
.uhm-wiki-view.ql-editor h2,
.uhm-wiki-view.ql-editor h3,
.uhm-wiki-view.ql-editor h4,
.uhm-wiki-view.ql-editor h5,
.uhm-wiki-view.ql-editor h6 {
scroll-margin-top: 16px;
.wiki-article a {
text-decoration: none;
}
.uhm-wiki-view.ql-editor a {
.wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
color: #3366cc;
}
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
text-decoration: underline;
text-decoration-thickness: from-font;
text-underline-offset: 2px;
}
.uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #2563eb;
}
:is(.dark *) .uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #60a5fa;
}
.uhm-wiki-view.ql-editor a[href="__missing__"] {
.wiki-article a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.uhm-wiki-view.ql-editor a:not([href]),
.uhm-wiki-view.ql-editor a[href=""],
.uhm-wiki-view.ql-editor a[href="__missing__"] {
.wiki-article a:not([href]),
.wiki-article a[href=""],
.wiki-article a[href="__missing__"] {
color: #dc2626;
}
:is(.dark *) .uhm-wiki-view.ql-editor a:not([href]),
:is(.dark *) .uhm-wiki-view.ql-editor a[href=""],
:is(.dark *) .uhm-wiki-view.ql-editor a[href="__missing__"] {
color: #f87171;
}
`}</style>
</div>
);
+3
View File
@@ -5,6 +5,7 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "outline"; // Button variant
startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after the text
title?: string; // Title text
};
const Button: React.FC<ButtonProps> = ({
@@ -16,6 +17,7 @@ const Button: React.FC<ButtonProps> = ({
className = "",
disabled = false,
type = "button",
title,
...rest
}) => {
// Size Classes
@@ -39,6 +41,7 @@ const Button: React.FC<ButtonProps> = ({
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
title={title}
disabled={disabled}
type={type}
{...rest}
+1
View File
@@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`,
entities: `${API_BASE_URL}/entities`,
wikis: `${API_BASE_URL}/wikis`,
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
// New API uses projects + commits + submissions (JWT-protected).
authSignin: `${API_BASE_URL}/auth/signin`,
authRefresh: `${API_BASE_URL}/auth/refresh`,
+11
View File
@@ -1,3 +1,4 @@
import api from "@/config/config";
import { API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, requestJson } from "@/uhm/api/http";
@@ -10,6 +11,11 @@ export type Wiki = {
is_deleted?: boolean;
created_at?: string;
updated_at?: string;
content_sample?:{
created_at?: string;
content?: string;
id?: string;
}[];
};
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
@@ -60,3 +66,8 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
return true;
}
export const getContentByVersionWikiId = async (id: string) => {
const response = await api.get(API_ENDPOINTS.wikiContent(id));
return response?.data;
};
@@ -28,6 +28,7 @@ type QuillLike = {
type QuillModule = {
Quill?: {
import?: (path: string) => unknown;
register?: (pathOrModule: unknown, moduleOrOverwrite?: unknown, overwrite?: boolean) => void;
};
};
type QuillLinkFormat = {
@@ -109,6 +110,43 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const mod = await import("react-quill-new") as QuillModule;
const Quill = mod?.Quill;
if (!Quill) return;
try {
const BlotFormatterModule = await import("quill-blot-formatter");
const BlotFormatter = BlotFormatterModule.default;
// Only register if not already registered to avoid errors in dev/HMR
Quill.register?.("modules/blotFormatter", BlotFormatter, true);
} catch (err) {
console.error("Failed to load quill-blot-formatter", err);
}
const ImageFormat = Quill.import?.("formats/image") as any;
if (ImageFormat) {
class CustomImage extends ImageFormat {
static formats(domNode: Element) {
const formats = ImageFormat.formats(domNode) || {};
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
return formats;
}
format(name: string, value: string) {
if (["style", "width", "height", "class"].includes(name)) {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
}
Quill.register?.(CustomImage, true);
}
const Link = Quill.import?.("formats/link");
if (!Link) return;
@@ -537,6 +575,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
},
},
},
blotFormatter: {},
};
}, []);