Compare commits

...

39 Commits

Author SHA1 Message Date
BoKhongLo 7d774440a9 style /user/projects page
Build and Release / release (push) Failing after 27s
2026-05-14 16:56:15 +07:00
BoKhongLo b54fdb987e restyle project/[id] page
Build and Release / release (push) Has been cancelled
2026-05-14 16:12:26 +07:00
BoKhongLo f904f91a9c update wiki page
Build and Release / release (push) Has been cancelled
2026-05-14 16:07:16 +07:00
BoKhongLo 3c71249926 update wiki 2026-05-14 12:55:10 +07:00
taDuc 8fc9456a6a init: replay mode 2026-05-14 03:41:58 +07:00
taDuc c92aaafc33 refactor: remove refresh token handling in favor of HttpOnly cookie-based authentication 2026-05-13 17:45:56 +07:00
taDuc f1d6f22f80 fix: refine token expiration detection and prevent unauthorized redirects for anonymous users while adding support for jwt property in token stores 2026-05-13 17:41:25 +07:00
taDuc 14a06af343 feat: implement updating circle 2026-05-13 15:48:00 +07:00
taDuc 41e43d4974 fix: stop use int key in local 2026-05-13 04:17:22 +07:00
taDuc 33a866b659 add new list view for ent - wiki 2026-05-13 02:40:48 +07:00
taDuc 08120ef987 refactor undo feature 2026-05-13 02:27:54 +07:00
taDuc e725b52590 editor panel improve experience 2026-05-12 21:54:56 +07:00
taDuc cb3e720644 editor UI for better experience :)) 2026-05-12 21:35:27 +07:00
taDuc 94c58e1d42 fix: filter geometry binding 2026-05-12 20:57:37 +07:00
taDuc 72d7073c40 refactor: path's label viewed also line 2026-05-12 20:48:13 +07:00
taDuc 51f432f0fe refactor: easy polylabel algorithm to view polygon label 2026-05-12 18:43:12 +07:00
taDuc 7b1f7538ab refactor: point view 2026-05-12 17:42:59 +07:00
taDuc d40c3831cb feat: implement modularized map layer styles for various geographic types and update map engine integration 2026-05-12 14:47:00 +07:00
taDuc 8f6d848d55 refactor: migrate project data models and transition editor state management to the new project-based API architecture. 2026-05-12 05:18:54 +07:00
taDuc 8f911abe35 refactor: rename entity type configuration to geometry type and relocate to dedicated map utilities. 2026-05-12 04:49:57 +07:00
taDuc 16fce9da7a refactor: reorganize project structure by migrating engine and geometry utilities into a structured map directory 2026-05-12 04:43:50 +07:00
taDuc 6076f098fa refactor: reorganize project components into subdirectories and update import paths for better maintainability. 2026-05-12 04:34:06 +07:00
taDuc eecedec560 refactor: modularize editor UI by extracting components into individual files 2026-05-12 04:20:55 +07:00
taDuc 1b321da6aa refactor: remove unused AuthPanel component placeholder 2026-05-12 04:09:10 +07:00
taDuc 1baba25303 refactor: modularize Map component logic into dedicated hooks for map instance management, layers, interactions, and state synchronization 2026-05-12 04:05:00 +07:00
taDuc ac8b0404dd feat: consolidate auth logic into axios interceptors and refactor http layer 2026-05-12 01:21:56 +07:00
taDuc fe7696b72d feat: Support multi-select editor workflow and improve UI/UX
- Refactored state from single selectedFeatureId to selectedFeatureIds array in Editor and Viewer
- Updated Map component to support multi-select filtering for geometry binding visibility
- Made entity, wiki, and geometry side panels scrollable for better overflow handling
- Fixed viewer mode wiki link navigation for independent wikis
- Improved geometry binding UX and state synchronization
2026-05-11 04:49:28 +07:00
taDuc f2f5295218 fix: prevent geometry clipping loss on edit by using unclipped feature from draft 2026-05-11 02:09:20 +07:00
taDuc 91d9d20447 feat: implement shift/alt snapping to geometry edges in polygon drawing 2026-05-11 01:16:01 +07:00
taDuc 9be308b65c fix confuse UI commit 2026-05-10 23:38:07 +07:00
taDuc c371d70993 default user UI 2026-05-10 21:34:21 +07:00
taDuc b14f11574b custom wiki editor link 2026-05-10 13:21:36 +07:00
taDuc 31297c8b59 wiki page 2026-05-10 03:25:47 +07:00
taDuc 78824ed07a configuration wiki editor link to bind with slug 2026-05-10 01:28:41 +07:00
taDuc 655454d83a Merge branch 'master' of github.com:Pregnant-Guild/FE_User_history_web 2026-05-09 23:03:26 +07:00
taDuc 6757eb2086 time query by range | filter by type 2026-05-09 23:02:48 +07:00
BoKhongLo cf880f573f quick question, answer
Build and Release / release (push) Successful in 33s
2026-05-09 16:22:22 +07:00
BoKhongLo 3ec9c51085 update about us
Build and Release / release (push) Successful in 35s
2026-05-09 16:01:01 +07:00
BoKhongLo f5fa38ec5a about-us
Build and Release / release (push) Successful in 34s
2026-05-09 15:19:40 +07:00
131 changed files with 10093 additions and 4512 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+182 -61
View File
@@ -28,6 +28,8 @@
"flatpickr": "^4.6.13",
"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",
@@ -47,6 +49,7 @@
"@eslint/eslintrc": "^3.3.1",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20.19.25",
"@types/polylabel": "^1.1.3",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@types/react-transition-group": "^4.4.12",
@@ -100,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",
@@ -1953,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"
}
@@ -3025,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"
@@ -3049,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"
},
@@ -3226,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",
@@ -3669,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"
@@ -3986,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",
@@ -4134,18 +4130,23 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/polylabel": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
"integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4156,7 +4157,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4231,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",
@@ -4763,7 +4762,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4819,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",
@@ -5231,7 +5228,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5250,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",
@@ -5282,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",
@@ -5367,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",
@@ -5651,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",
@@ -5672,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",
@@ -5690,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",
@@ -6094,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",
@@ -6280,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",
@@ -6521,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",
@@ -6773,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"
@@ -6961,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"
@@ -7113,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",
@@ -7265,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",
@@ -7394,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",
@@ -7580,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",
@@ -8444,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"
@@ -8619,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",
@@ -8724,6 +8782,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/polylabel": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz",
"integrity": "sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==",
"license": "ISC",
"dependencies": {
"tinyqueue": "^3.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8753,7 +8820,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8918,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"
}
@@ -8948,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",
@@ -8997,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",
@@ -9066,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": {
@@ -9085,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",
@@ -9095,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"
}
@@ -9162,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"
},
@@ -9208,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"
@@ -9236,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",
@@ -9295,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",
@@ -9538,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",
@@ -9556,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",
@@ -10061,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",
@@ -10118,7 +10242,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10296,7 +10419,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10675,7 +10797,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+4 -1
View File
@@ -29,6 +29,8 @@
"flatpickr": "^4.6.13",
"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",
@@ -37,17 +39,18 @@
"react-dropzone": "^14.3.8",
"react-quill-new": "^3.8.3",
"react-redux": "^9.2.0",
"uuid": "^13.0.0",
"sonner": "^2.0.7",
"sweetalert2": "^11.26.24",
"swiper": "^11.2.10",
"tailwind-merge": "^2.6.0",
"uuid": "^13.0.0",
"yet-another-react-lightbox": "^3.30.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20.19.25",
"@types/polylabel": "^1.1.3",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@types/react-transition-group": "^4.4.12",
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+25 -12
View File
@@ -12,11 +12,15 @@ import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionType
type EditorDraftApi = {
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
patchFeaturePropertiesBatch: (
patches: Array<{ id: FeatureProperties["id"]; patch: Partial<FeatureProperties> }>,
label?: string
) => void;
};
type Options = {
editor: EditorDraftApi;
selectedFeature: Feature | null;
selectedFeatures: Feature[];
geometryMetaForm: GeometryMetaFormState;
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
selectedGeometryEntityIds: string[];
@@ -29,7 +33,7 @@ type Options = {
export function useFeatureCommands(options: Options) {
const {
editor,
selectedFeature,
selectedFeatures,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
@@ -40,8 +44,8 @@ export function useFeatureCommands(options: Options) {
} = options;
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeature) {
const msg = "Hãy chọn một geometry trước.";
if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn ít nhất một geometry trước.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
@@ -64,7 +68,13 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
editor.patchFeaturePropertiesBatch(
selectedFeatures.map((feature) => ({
id: feature.properties.id,
patch: metadata.patch,
})),
"Cập nhật thuộc tính GEO"
);
setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
return { ok: true };
@@ -74,15 +84,15 @@ export function useFeatureCommands(options: Options) {
}, [
editor,
geometryMetaForm,
selectedFeature,
selectedFeatures,
setEntityFormStatus,
setGeometryMetaForm,
setIsEntitySubmitting,
]);
const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
if (!selectedFeatures || selectedFeatures.length === 0) {
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
return;
}
@@ -90,9 +100,12 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(
selectedFeature.properties.id,
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
editor.patchFeaturePropertiesBatch(
selectedFeatures.map((feature) => ({
id: feature.properties.id,
patch: buildFeatureEntityPatch(feature, entityIds, entities),
})),
"Cập nhật entity cho GEO"
);
setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
@@ -108,7 +121,7 @@ export function useFeatureCommands(options: Options) {
}, [
editor,
entities,
selectedFeature,
selectedFeatures,
selectedGeometryEntityIds,
setEntityFormStatus,
setIsEntitySubmitting,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ export default function RootLayout({
<html lang="en">
<body className={`${inter.className} dark:bg-gray-900`}>
<StoreProvider>
<ThemeProvider>
<ThemeProvider>
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
</ThemeProvider>
</StoreProvider>
+770 -165
View File
@@ -1,186 +1,791 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Map from "@/uhm/components/Map";
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
import TimelineBar from "@/uhm/components/TimelineBar";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import { fetchEntities, type Entity } from "@/uhm/api/entities";
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
import { ApiError } from "@/uhm/api/http";
import { API_BASE_URL } from "@/uhm/api/config";
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import {
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/backgroundLayers";
BACKGROUND_LAYER_OPTIONS,
type BackgroundLayerId,
type BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers";
import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
import type { FeatureCollection } from "@/uhm/types/geo";
const CURRENT_YEAR = new Date().getUTCFullYear();
const ENTITY_PAGE_LIMIT = 100;
const WIKI_PAGE_LIMIT = 100;
const RELATION_CONCURRENCY = 6;
type RelationIndex = {
entitiesById: Record<string, Entity>;
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
geometryEntityIds: Record<string, string[]>;
wikiEntityIdsBySlug: Record<string, string[]>;
wikiBySlug: Record<string, Wiki>;
};
type LinkEntityPopupState = {
slug: string;
entities: Entity[];
top: number;
left: number;
};
const EMPTY_RELATIONS: RelationIndex = {
entitiesById: {},
entityGeometriesById: {},
entityWikisById: {},
geometryEntityIds: {},
wikiEntityIdsBySlug: {},
wikiBySlug: {},
};
export default function Page() {
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
const selectedFeature: Feature | null = useMemo(() => {
if (selectedFeatureId === null) return null;
return (
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timeRange, setTimeRange] = useState<number>(0);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
}, [data.features, selectedFeatureId]);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) init[key] = true;
return init;
});
const [relations, setRelations] = useState<RelationIndex>(EMPTY_RELATIONS);
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
const [relationsProgress, setRelationsProgress] = useState<{ completed: number; total: number }>({
completed: 0,
total: 0,
});
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
const [entityFocusToken, setEntityFocusToken] = useState(0);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
if (!stillExists) setSelectedFeatureId(null);
}, [data.features, selectedFeatureId]);
const timelineFetchRequestRef = useRef(0);
const hoverHideTimerRef = useRef<number | null>(null);
const hoverPopupHoveredRef = useRef(false);
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear]);
const selectedFeature = useMemo(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
return (
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
);
}, [data.features, selectedFeatureIds]);
useEffect(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, []);
useEffect(() => {
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setTimelineStatus(null);
try {
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear });
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setData(next);
setLastLoadedAt(new Date().toISOString());
} catch (err) {
if (err instanceof ApiError) {
console.error("Load timeline data failed", err.body);
} else {
console.error("Load timeline data failed", err);
useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
data.features.some(feature => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
}, [data.features, selectedFeatureIds]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear]);
useEffect(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, []);
useEffect(() => {
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setTimelineStatus(null);
try {
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setData(next);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load timeline data failed", err.body);
} else {
console.error("Load timeline data failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear, timeRange]);
useEffect(() => {
let disposed = false;
async function loadRelations() {
setIsRelationsLoading(true);
setRelationsStatus(null);
setRelationsProgress({ completed: 0, total: 0 });
try {
const entities = await fetchAllEntities();
if (disposed) return;
const next: RelationIndex = {
entitiesById: {},
entityGeometriesById: {},
entityWikisById: {},
geometryEntityIds: {},
wikiEntityIdsBySlug: {},
wikiBySlug: {},
};
for (const entity of entities) {
next.entitiesById[entity.id] = entity;
}
setRelationsProgress({ completed: 0, total: entities.length });
await mapWithConcurrency(entities, RELATION_CONCURRENCY, async (entity, index) => {
const [geometries, wikis] = await Promise.all([
fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id }),
fetchAllWikisForEntity(entity.id),
]);
if (disposed) return;
next.entityGeometriesById[entity.id] = geometries;
next.entityWikisById[entity.id] = wikis;
for (const feature of geometries.features) {
pushUniqueString(next.geometryEntityIds, String(feature.properties.id), entity.id);
}
for (const wiki of wikis) {
const slug = String(wiki.slug || "").trim();
if (!slug.length) continue;
next.wikiBySlug[slug] = wiki;
pushUniqueString(next.wikiEntityIdsBySlug, slug, entity.id);
}
const completed = index + 1;
if (completed === entities.length || completed % 5 === 0) {
setRelationsProgress({ completed, total: entities.length });
}
});
if (disposed) return;
normalizeRelationArrays(next.geometryEntityIds);
normalizeRelationArrays(next.wikiEntityIdsBySlug);
setRelations(next);
setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev }));
} catch (err) {
console.error("Load relation index failed", err);
if (!disposed) {
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
}
} finally {
if (!disposed) {
setIsRelationsLoading(false);
}
}
}
}
loadRelations();
return () => {
disposed = true;
};
}, []);
const hoverEntityIds = useMemo(() => {
if (!hoverAnchor) return [];
return relations.geometryEntityIds[String(hoverAnchor.featureId)] || [];
}, [hoverAnchor, relations.geometryEntityIds]);
const hoverEntities = useMemo(() => {
return hoverEntityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
}, [hoverEntityIds, relations.entitiesById]);
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
const activeEntityGeometries = activeEntityId
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
: EMPTY_FEATURE_COLLECTION;
const activeWiki = useMemo(() => {
if (!activeWikiSlug) return null;
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleShowAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const handleTimeRangeChange = (nextRange: number) => {
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
setTimeRange(Math.max(0, Math.min(30, safe)));
};
const clearHoverHideTimer = useCallback(() => {
if (hoverHideTimerRef.current !== null) {
window.clearTimeout(hoverHideTimerRef.current);
hoverHideTimerRef.current = null;
}
}, []);
const selectEntity = useCallback((
entityId: string,
options?: {
sourceFeatureId?: string | number | null;
preferredWikiSlug?: string | null;
focusMap?: boolean;
selectGeometry?: boolean;
}
) => {
const entity = relations.entitiesById[entityId] || null;
if (!entity) return;
const linkedWikis = relations.entityWikisById[entityId] || [];
const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim();
const nextWikiSlug =
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
? preferredWikiSlug
: "") ||
linkedWikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) ||
null;
setActiveEntityId(entityId);
setActiveWikiSlug(nextWikiSlug);
setActiveWikiError(null);
setLinkEntityPopup(null);
if (options?.focusMap !== false) {
setEntityFocusToken((prev) => prev + 1);
}
if (options?.selectGeometry && options?.sourceFeatureId != null) {
setSelectedFeatureIds([options.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById]);
useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
// For UI simplicity in viewer, just link to the first selected geometry
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return;
selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureIds[0],
focusMap: false,
selectGeometry: false,
});
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
clearHoverHideTimer();
if (payload) {
setHoverAnchor(payload);
return;
}
if (hoverPopupHoveredRef.current) return;
hoverHideTimerRef.current = window.setTimeout(() => {
setHoverAnchor(null);
}, 120);
}, [clearHoverHideTimer]);
useEffect(() => {
return () => {
if (hoverHideTimerRef.current !== null) {
window.clearTimeout(hoverHideTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!linkEntityPopup) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setLinkEntityPopup(null);
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && linkEntityPopupRef.current?.contains(target)) return;
setLinkEntityPopup(null);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [linkEntityPopup]);
useEffect(() => {
if (!activeWikiSlug) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
if (cached?.content) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const row = await fetchWikiBySlug(activeWikiSlug);
if (disposed) return;
if (row) {
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (disposed) return;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
const linkedEntities = linkedEntityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (linkedEntities.length === 1) {
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
return;
}
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
try {
const row = await fetchWikiBySlug(slug);
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
} catch (err) {
console.error("Load wiki by slug failed", err);
}
}
if (!linkedEntities.length) return;
const popupWidth = 240;
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
setLinkEntityPopup({
slug,
entities: linkedEntities,
top,
left,
});
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
const helperText = isRelationsLoading
? `Đang index entity/wiki ${relationsProgress.completed}/${relationsProgress.total || "?"}`
: relationsStatus || `Features: ${data.features.length}`;
return (
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
<div className="relative min-h-screen">
{isBackgroundVisibilityReady ? (
<Map
mode="select"
draft={data}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
respectBindingFilter={true}
onHoverFeatureChange={handleMapHoverChange}
highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries}
focusRequestKey={entityFocusToken}
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
/>
) : (
<div className="h-screen w-full bg-[#0b1220]" />
)}
<TimelineBar
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
isLoading={isTimelineLoading}
disabled={false}
statusText={timelineStatus}
/>
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
<div className="border-b border-white/10 px-4 py-3">
<div className="text-sm font-semibold text-white">Map Layers</div>
<div className="mt-1 text-xs text-slate-400">{helperText}</div>
</div>
<div className="grid gap-4 px-4 py-4">
<div>
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
<span>Background</span>
<div className="flex gap-2">
<button type="button" onClick={handleShowAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
All
</button>
<button type="button" onClick={handleHideAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
Off
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
onClick={() => handleToggleBackgroundLayer(layer.id)}
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`}
>
{layer.label}
</button>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
Geometry
</div>
<div className="flex flex-wrap gap-2">
{GEO_TYPE_KEYS.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</div>
</div>
</div>
{hoverAnchor && hoverEntities.length > 0 ? (
<div
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
style={{
left: clampNumber(hoverAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : hoverAnchor.point.x + 18),
top: clampNumber(hoverAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : hoverAnchor.point.y - 8),
}}
onMouseEnter={() => {
hoverPopupHoveredRef.current = true;
clearHoverHideTimer();
}}
onMouseLeave={() => {
hoverPopupHoveredRef.current = false;
setHoverAnchor(null);
}}
>
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
{hoverEntities.length > 1 ? (
<div className="border-b border-white/10 px-4 py-3">
<div className="text-sm font-semibold text-white">Related Entities</div>
<div className="mt-1 text-xs text-slate-400">
Geometry #{String(hoverAnchor.featureId)}
</div>
</div>
) : null}
<div className="max-h-[252px] overflow-y-auto">
<div className="grid gap-2 p-3">
{hoverEntities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
selectEntity(entity.id, {
sourceFeatureId: hoverAnchor.featureId,
focusMap: true,
selectGeometry: true,
});
setHoverAnchor(null);
}}
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-3 text-left transition hover:border-sky-400/40 hover:bg-sky-500/10"
>
<div className="truncate text-sm font-semibold text-white">
{entity.name}
</div>
<div
className="mt-1 text-xs leading-5 text-slate-400"
style={{
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{entity.description?.trim() || "Không có mô tả."}
</div>
</button>
))}
</div>
</div>
</div>
</div>
) : null}
{activeEntity ? (
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
<PublicWikiSidebar
entity={activeEntity}
wiki={activeWiki}
isLoading={isActiveWikiLoading}
error={activeWikiError}
onClose={() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
}}
onWikiLinkRequest={handleWikiLinkRequest}
/>
</aside>
) : null}
</div>
{linkEntityPopup ? (
<div
ref={linkEntityPopupRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{linkEntityPopup.slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{linkEntityPopup.entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
setLinkEntityPopup(null);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
</div>
) : null}
</div>
);
}
async function fetchAllEntities(): Promise<Entity[]> {
const items: Entity[] = [];
const seen = new Set<string>();
let cursor: string | undefined;
while (true) {
const page = await fetchEntities({ q: "", limit: ENTITY_PAGE_LIMIT, cursor });
if (!page.length) break;
for (const entity of page) {
if (!entity?.id || seen.has(entity.id)) continue;
seen.add(entity.id);
items.push(entity);
}
if (page.length < ENTITY_PAGE_LIMIT) break;
const nextCursor = page[page.length - 1]?.id;
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear]);
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleShowAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
return (
<div style={{ display: "flex", minHeight: "100vh" }}>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? (
<Map
mode="select"
draft={data}
selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId}
backgroundVisibility={backgroundVisibility}
allowGeometryEditing={false}
respectBindingFilter={false}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<TimelineBar
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading}
disabled={false}
statusText={timelineStatus}
/>
</div>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
display: "grid",
gap: "8px",
}}
>
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>Viewer</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
API: {API_BASE_URL}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Year: {timelineYear} | Features: {data.features.length}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
{isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"}
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px", overflowWrap: "anywhere" }}>
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
{selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"}
</div>
</div>
}
/>
</div>
);
return items;
}
async function fetchAllWikisForEntity(entityId: string): Promise<Wiki[]> {
const items: Wiki[] = [];
const seen = new Set<string>();
let cursor: string | undefined;
while (true) {
const page = await searchWikisByTitle("", {
entityId,
limit: WIKI_PAGE_LIMIT,
cursor,
});
if (!page.length) break;
for (const wiki of page) {
if (!wiki?.id || seen.has(wiki.id)) continue;
seen.add(wiki.id);
items.push(wiki);
}
if (page.length < WIKI_PAGE_LIMIT) break;
const nextCursor = page[page.length - 1]?.id;
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return items;
}
async function mapWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>
): Promise<void> {
const runnerCount = Math.max(1, Math.min(concurrency, items.length));
let nextIndex = 0;
await Promise.all(
Array.from({ length: runnerCount }, async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
await worker(items[current], current);
}
})
);
}
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) {
target[key].push(value);
}
}
function normalizeRelationArrays(target: Record<string, string[]>) {
for (const key of Object.keys(target)) {
target[key] = Array.from(new Set(target[key]));
}
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (value < min) return min;
if (value > max) return max;
return value;
}
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
const margin = 12;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, viewportWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, viewportHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
return { top, left };
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import React from "react";
import Image from "next/image";
import Link from "next/link";
export default function LandingPage() {
const features = [
{
title: "Bản đồ dòng thời gian",
desc: "Giao diện bản đồ tự động thay đổi biên giới, địa danh và sự kiện tương ứng với mốc thời gian được lựa chọn.",
icon: "🗺️",
},
{
title: "Tương tác thực tế",
desc: "Hiển thị chi tiết bối cảnh, nhân vật và số liệu khi người dùng thao tác vào các điểm neo sự kiện trên bản đồ.",
icon: "📍",
},
{
title: "Trợ lý ảo & Công cụ học",
desc: "Tích hợp AI giải đáp thắc mắc lịch sử, kết hợp hệ thống giao bài tập và làm Quiz trực tuyến cho học đường.",
icon: "🤖",
},
];
const team = [
{
name: "Trần Anh Đức",
role: "Project Manager",
desc: "Fan cứng anh Lại Ngứa Chân",
avatar: "/images/teamdev/tad.jpeg",
},
{
name: "Đỗ Duy Khánh",
role: "Backend Developer",
desc: "Kì nhân dị sỹ",
avatar: "/images/teamdev/ddk2.jpeg",
},
{
name: "Ngô Cung Đức Anh",
role: "Frontend Developer",
desc: "Cũng đẹp trai nhưng cao m7 thôi",
avatar: "/images/teamdev/ncda.jpeg",
},
];
return (
// Sử dụng tông màu Vàng cổ (Parchment) và Xanh rêu (Dark Slate Green)
<div className="relative min-h-screen w-full text-[#2D3A3A] font-sans selection:bg-[#A88B4C] selection:text-white overflow-x-hidden">
{/* --- BACKGROUND IMAGE --- */}
<div className="fixed inset-0 -z-20 pointer-events-none">
<Image
src="/images/map.jpeg"
alt="World Map Background"
fill
className="object-cover object-center opacity-40"
priority
/>
</div>
{/* Lớp overlay mờ để làm dịu background */}
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
{/* --- HEADER NAVBAR --- */}
<header className="fixed top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-50 border-b border-[#A88B4C]/20">
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
<span className="text-[#A88B4C]">Geo</span>History
</div>
<nav className="flex gap-4 items-center">
<Link
href="/auth/signin"
className="text-sm font-semibold text-[#2D3A3A] hover:text-[#A88B4C] transition-colors"
>
Đăng nhập
</Link>
<Link
href="/user"
className="text-sm font-semibold px-4 py-2 bg-[#2D3A3A] text-[#FDFBF7] rounded-lg hover:bg-[#1a2323] transition-colors"
>
Vào Hệ thống
</Link>
</nav>
</header>
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col gap-32 w-full relative">
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
<section className="min-h-[70vh] flex flex-col justify-center relative">
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
Bách khoa toàn thư <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#A88B4C] to-[#806835]">
Bản đ số Lịch sử
</span>
</h1>
<div className="max-w-3xl space-y-6 text-lg md:text-xl text-[#4A5555] leading-relaxed">
<p>
Hệ thống thông tin đa (GIS) tiên phong trong việc trực quan
hóa dữ liệu lịch sử. Nền tảng của chúng tôi cho phép hiển thị đng
các thông tin như biên giới quốc gia, diễn biến trận chiến sự
kiện theo đúng tiến trình thời gian.
</p>
<p>
Đây không gian tập trung tri thức đưc tinh lọc, nơi các chuyên
gia, nhà sử học giáo viên đóng góp dữ liệu tọa đ, vector, đưc
hệ thống kiểm duyệt chặt chẽ trước khi xuất bản.
</p>
</div>
<div className="mt-10 flex gap-4">
<Link
href="#mission"
className="px-8 py-4 bg-[#A88B4C] text-white font-bold rounded-xl shadow-lg shadow-[#A88B4C]/20 hover:bg-[#8e743c]"
>
Khám phá sứ mệnh
</Link>
</div>
</section>
{/* --- PHẦN 2: SỨ MỆNH & CHỨC NĂNG --- */}
<section id="mission" className="scroll-mt-24 relative">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-center">
{/* Sứ mệnh */}
<div>
<div className="inline-block px-3 py-1 bg-[#A88B4C]/10 text-[#A88B4C] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#A88B4C]/20">
Sứ mệnh của chúng tôi
</div>
<h2 className="text-3xl md:text-4xl font-bold mb-6">
Đnh hình lại cách học Lịch sử
</h2>
<div className="space-y-6 text-[#4A5555]">
<div>
<h3 className="text-xl font-bold text-[#2D3A3A] mb-2 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-[#A88B4C]/20 flex items-center justify-center text-[#A88B4C]">
1
</span>
Giải quyết rào cản giáo dục
</h3>
<p>
Khắc phục sự nhàm chán khó tiếp cận của phương pháp học
lịch sử truyền thống bằng cách biến dữ liệu chữ viết thành
hình nh không gian, thời gian trực quan.
</p>
</div>
<div className="w-full h-px bg-[#A88B4C]/20"></div>
<div>
<h3 className="text-xl font-bold text-[#2D3A3A] mb-2 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-[#A88B4C]/20 flex items-center justify-center text-[#A88B4C]">
2
</span>
Tập trung hóa tri thức
</h3>
<p>
Xây dựng một kho dữ liệu lịch sử thống nhất, chuẩn xác, phục
vụ đa dạng đi tượng từ chính phủ, chuyên gia nghiên cứu đến
học sinh, sinh viên cộng đng.
</p>
</div>
</div>
</div>
{/* Chức năng */}
<div>
<div className="inline-block px-3 py-1 bg-[#2D3A3A]/10 text-[#2D3A3A] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#2D3A3A]/20">
Tính năng cốt lõi
</div>
<h2 className="text-3xl md:text-4xl font-bold mb-6">
Công nghệ hội tụ
</h2>
<div className="flex flex-col gap-4">
{features.map((feat, idx) => (
<div
key={idx}
className="flex gap-4 items-start p-5 hover:bg-[#A88B4C]/10 rounded-2xl group"
>
<div className="text-3xl bg-transparent w-14 h-14 rounded-xl flex items-center justify-center shrink-0">
{feat.icon}
</div>
<div>
<h4 className="font-bold text-[#2D3A3A] text-lg mb-1">
{feat.title}
</h4>
<p className="text-sm text-[#4A5555] leading-relaxed">
{feat.desc}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
{/* --- PHẦN 3: ĐỘI NGŨ PHÁT TRIỂN --- */}
<section className="relative">
<div className="text-center max-w-2xl mx-auto mb-12">
<div className="inline-block px-3 py-1 bg-[#A88B4C]/10 text-[#A88B4C] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#A88B4C]/20">
Về chúng tôi
</div>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Đi ngũ phát triển
</h2>
<p className="text-[#4A5555]">
Những con người đam lịch sử công nghệ chung tay xây dựng cỗ
máy thời gian kỹ thuật số.
</p>
</div>
<div className="flex mx-auto justify-center gap-12 flex-wrap">
{team.map((member, i) => (
<div key={i} className="p-6 text-center min-w-[264px]">
<div className="w-24 aspect-square mx-auto bg-gradient-to-tr from-[#A88B4C]/20 to-[#2D3A3A]/20 rounded-full mb-4 flex items-center justify-center overflow-hidden">
<Image
src={member.avatar}
alt={member.name}
width={96}
height={96}
className="w-full h-full object-cover object-center rounded-full"
/>
</div>
<h3 className="font-bold text-lg text-[#2D3A3A]">
{member.name}
</h3>
<p className="text-xs font-bold text-[#A88B4C] uppercase tracking-wider my-2">
{member.role}
</p>
<p className="text-sm text-[#4A5555]">{member.desc}</p>
</div>
))}
</div>
</section>
</main>
{/* --- PHẦN 4: GÓP Ý & LIÊN HỆ --- */}
<section className="relative mt-8 mb-16 w-full">
<div className="flex flex-col lg:flex-row justify-between gap-10 border rounded-2xl p-8 bg-[#FDFBF7]/80 backdrop-blur-sm border-[#A88B4C]/20">
{/* Box Text */}
<div className="lg:w-1/4 text-center lg:text-left flex flex-col justify-center">
<h2 className="text-2xl font-bold text-gray-900 mb-3">
Góp ý cho chúng tôi!
</h2>
<p className="text-sm text-gray-500 leading-relaxed">
Đăng nhận tin tức mới nhất hoặc đ lại ý kiến đóng góp giúp hệ
thống hoàn thiện hơn.
</p>
</div>
{/* Box Form Nhập Liệu (Dòng trên - Dòng dưới) */}
<div className="flex-1 w-full max-w-3xl flex flex-col gap-4">
<input
type="email"
placeholder="Email của bạn..."
className="w-full px-5 py-3.5 text-gray-700 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:bg-white focus:border-[#FFDE00] focus:ring-4 focus:ring-[#FFDE00]/20 transition-all text-sm placeholder:text-gray-400"
/>
<textarea
placeholder="Nội dung góp ý của bạn..."
rows={3}
className="w-full px-5 py-3.5 text-gray-700 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:bg-white focus:border-[#FFDE00] focus:ring-4 focus:ring-[#FFDE00]/20 transition-all text-sm placeholder:text-gray-400 resize-none"
></textarea>
<div className="flex justify-end">
<button className="bg-[#FFDE00] hover:bg-[#F0D100] text-black font-bold uppercase tracking-wide px-8 py-3.5 rounded-xl transition-colors text-sm shadow-sm">
Gửi Góp Ý
</button>
</div>
</div>
{/* Box Socials */}
<div className="lg:w-auto flex flex-col items-center lg:items-start pl-0 lg:pl-8 border-t lg:border-t-0 lg:border-l border-gray-100 pt-8 lg:pt-0 justify-center">
<h3 className="text-lg font-bold text-gray-900 mb-5">Follow us</h3>
<div className="flex gap-4">
<a
href="https://www.youtube.com/@BlackCatStudio-mw2sq"
target="_blank"
rel="noopener noreferrer"
className="w-12 h-12 rounded-full bg-[#FF0000] flex items-center justify-center text-white hover:opacity-90 hover:-translate-y-1 transition-all shadow-lg group"
>
<svg className="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
</a>
</div>
</div>
</div>
</section>
{/* FOOTER */}
<footer className="border-t border-[#A88B4C]/20 bg-[#2D3A3A] text-white py-12 text-center w-full mt-auto rounded-2xl">
<div className="max-w-6xl mx-auto px-6 flex flex-col items-center">
<div className="text-2xl font-bold tracking-widest uppercase mb-4">
<span className="text-[#A88B4C]">Geo</span>History
</div>
<p className="text-gray-400 text-sm mb-4 max-w-md">
Bách khoa toàn thư bản đ số lịch sử. Kết nối quá khứ, thấu hiểu
hiện tại, kiến tạo tương lai.
</p>
<div className="text-xs text-gray-500">
&copy; {new Date().getFullYear()} GeoHistory Project. All rights
reserved.
</div>
</div>
</footer>
</div>
);
}
+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",
+103 -106
View File
@@ -14,7 +14,7 @@ import Badge from "@/components/ui/badge/Badge";
import { CreateProjectPayload, Project } from "@/interface/project";
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { EditorSnapshot } from "@/uhm/types/sections";
import type { EditorSnapshot } from "@/uhm/types/projects";
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
@@ -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>
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
const faqData = [
{
id: 1,
question: "1. Phần mềm này tương thích với những hệ điều hành nào?",
answer: "Hệ thống tương thích hoàn toàn với Windows 10/11, macOS 12 trở lên. Đối với môi trường máy chủ, chúng tôi hỗ trợ các bản phân phối Linux phổ biến như Ubuntu và Debian."
},
{
id: 2,
question: "2. Sự khác biệt giữa phiên bản Miễn phí và Trả phí là gì?",
answer: "Phiên bản trả phí cung cấp băng thông không giới hạn, hỗ trợ kỹ thuật ưu tiên 24/7, và quyền truy cập sớm vào các tính năng nâng cao. Bản miễn phí sẽ giới hạn một số tính năng xuất dữ liệu."
},
{
id: 3,
question: "3. Hệ thống hỗ trợ những phương thức kết nối nào?",
answer: "Chúng tôi hỗ trợ kết nối qua REST API, WebSocket cho dữ liệu thời gian thực và cung cấp sẵn SDK cho các ngôn ngữ phổ biến như TypeScript, Go."
},
{
id: 4,
question: "4. Làm thế nào để tôi có thể tích hợp vào dự án Next.js hiện tại?",
answer: "Bạn chỉ cần cài đặt package qua npm/yarn, thêm API Key vào file .env và gọi component Provider ở file layout.tsx gốc. Tài liệu chi tiết có sẵn trong mục Developer Docs."
},
{
id: 5,
question: "5. Dữ liệu của tôi được bảo mật như thế nào?",
answer: "Toàn bộ dữ liệu được mã hóa đầu cuối (End-to-End Encryption). Chúng tôi tuân thủ nghiêm ngặt các tiêu chuẩn bảo mật quốc tế và thường xuyên rà soát hệ thống để phòng chống các lỗ hổng bảo mật."
}
];
export default function Page() {
// Lưu index của câu hỏi đang được mở. Mặc định mở câu đầu tiên (index 0).
const [openIndex, setOpenIndex] = useState<number | null>(0);
const toggleFAQ = (index: number) => {
// Nếu click lại vào câu đang mở thì đóng nó, ngược lại thì mở câu mới
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="min-h-screen bg-white text-slate-900 py-16 px-4 sm:px-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-10">FAQs</h1>
<div className="border-t border-slate-200">
{faqData.map((faq, index) => {
const isOpen = openIndex === index;
return (
<div key={faq.id} className="border-b border-slate-200">
<button
onClick={() => toggleFAQ(index)}
className="w-full py-6 flex justify-between items-center text-left focus:outline-none group"
>
<span className="text-lg font-bold group-hover:text-indigo-600 transition-colors">
{faq.question}
</span>
<span className="text-3xl font-light ml-4 text-indigo-600 shrink-0 leading-none">
{isOpen ? '' : '+'}
</span>
</button>
{/* Phần nội dung có hiệu ứng trượt */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isOpen ? 'max-h-96 opacity-100 pb-6' : 'max-h-0 opacity-0'
}`}
>
<p className="text-slate-600 text-base leading-relaxed pr-8">
{faq.answer}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import { createHistorianCV } from "@/service/historianService";
import { toast } from "sonner";
import { newId } from "@/uhm/lib/id";
import { newId } from "@/uhm/lib/utils/id";
import Swal from "sweetalert2";
import { PresignedUrlResponse } from "@/interface/media";
+1 -1
View File
@@ -209,7 +209,7 @@ export default function WikiEditorPage() {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section" }] },
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Project" }] },
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
],
},
+10
View File
@@ -0,0 +1,10 @@
import WikiBySlugClient from "./wiki-by-slug-client";
export default async function WikiBySlugPage({
params,
}: {
params: Promise<{ slug: string }> | { slug: string };
}) {
const resolved = await params;
return <WikiBySlugClient slug={resolved.slug} />;
}
+822
View File
@@ -0,0 +1,822 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import "react-quill-new/dist/quill.snow.css";
import { ApiError } from "@/uhm/api/http";
import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
level: number;
text: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function tiptapJsonToPlainText(node: unknown): string {
if (node == null) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
if (isRecord(node)) {
if (node.type === "text" && typeof node.text === "string") return node.text;
if (node.type === "hardBreak") return "\n";
if ("content" in node) return tiptapJsonToPlainText(node.content);
}
return "";
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
const value = String(raw || "").trim();
if (!value.length) return "";
// New format: HTML string.
if (value[0] === "<") return value;
// Legacy format: Tiptap JSON string.
if (value[0] === "{") {
try {
const json: unknown = JSON.parse(value);
const text = tiptapJsonToPlainText(json).trim();
if (!text.length) return "";
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
} catch {
// fall through
}
}
// Unknown plaintext: treat as plain text.
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isExternalHref(href: string): boolean {
const h = href.trim().toLowerCase();
return (
h.startsWith("http://") ||
h.startsWith("https://") ||
h.startsWith("mailto:") ||
h.startsWith("tel:") ||
h.startsWith("sms:")
);
}
function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
// Basic hardening: do not render scripts in user content.
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
// Rewrite internal wiki links: Quill stores slug as <a href="other-wiki-slug">...</a>
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
if (href.startsWith("#")) continue;
if (href.startsWith("/")) continue;
if (isExternalHref(href)) continue;
const match = href.match(/^([^?#]+)([?#].*)?$/);
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
const suffix = String(match?.[2] || "");
const normalizedSlug = slugPart;
if (!normalizedSlug.length) continue;
a.setAttribute("href", `${wikiBaseUrl}${encodeURIComponent(normalizedSlug)}${suffix}`);
a.setAttribute("target", "_self");
}
// Build TOC from headings and ensure they have stable IDs.
const toc: TocItem[] = [];
const seen = new Map<string, number>();
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
for (const h of headings) {
const text = String(h.textContent || "").trim();
if (!text.length) continue;
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
const existingId = String(h.getAttribute("id") || "").trim();
if (existingId) {
toc.push({ id: existingId, level, text });
continue;
}
const base = slugifyHeading(text) || "heading";
const n = (seen.get(base) || 0) + 1;
seen.set(base, n);
const id = n === 1 ? base : `${base}-${n}`;
h.setAttribute("id", id);
toc.push({ id, level, text });
}
return { html: doc.body.innerHTML, toc };
}
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(
"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);
const [linkPreview, setLinkPreview] = useState<{
slug: string;
top: number;
left: number;
width: number;
height: number;
visible: boolean;
} | null>(null);
const [linkPreviewData, setLinkPreviewData] = useState<{
slug: string;
title: string;
quote: string | null;
status: "idle" | "loading" | "ready" | "error";
} | null>(null);
const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]);
const contentRootRef = useRef<HTMLDivElement | null>(null);
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();
if (!value.length) {
setWiki(null);
setStatus("error");
setError("Missing wiki slug.");
return;
}
let disposed = false;
(async () => {
setStatus("loading");
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);
setStatus("ready");
setRenderHtml("");
setToc([]);
return;
}
setWiki({ ...res, content: versionContent });
setStatus("ready");
} catch (err) {
if (disposed) return;
const msg =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: "Failed to load wiki.";
setStatus("error");
setError(msg);
}
})();
return () => {
disposed = true;
};
}, [normalizedSlug]);
// Transform content: normalize -> rewrite internal links -> inject heading ids + toc.
useEffect(() => {
if (!wiki) {
setRenderHtml("");
setToc([]);
return;
}
const raw =
(wiki.content ?? (wiki as unknown as { doc?: string | null }).doc ?? "") || "";
const html = normalizeWikiContentToHtml(raw);
try {
const base = `${window.location.origin}/wiki/`;
const processed = rewriteHtmlAndBuildToc(html, base);
setRenderHtml(processed.html);
setToc(processed.toc);
setActiveHeadingId(processed.toc[0]?.id ?? null);
} catch (err) {
console.error("Failed to process wiki HTML", err);
setRenderHtml(html);
setToc([]);
}
}, [wiki]);
// Track active heading for TOC highlight.
useEffect(() => {
if (!toc.length) return;
const root = contentRootRef.current;
if (!root) return;
const headings = toc
.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.id)}`))
.filter((el): el is HTMLElement => Boolean(el));
if (!headings.length) return;
const obs = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
const top = visible[0]?.target as HTMLElement | undefined;
const id = top?.id || null;
if (id) setActiveHeadingId(id);
},
{ root: null, rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }
);
for (const h of headings) obs.observe(h);
return () => obs.disconnect();
}, [toc]);
// Hover preview for internal wiki links (title + first blockquote).
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
if (typeof window === "undefined") return;
const clearHideTimer = () => {
if (hidePreviewTimerRef.current != null) {
window.clearTimeout(hidePreviewTimerRef.current);
hidePreviewTimerRef.current = null;
}
};
const hideSoon = () => {
clearHideTimer();
hidePreviewTimerRef.current = window.setTimeout(() => {
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
}, 140);
};
const resolveInternalWikiSlug = (href: string): string | null => {
const h = href.trim();
if (!h.length) return null;
if (h === "__missing__") return null;
if (h.startsWith("#")) return null;
const stripQueryHash = (s: string) => {
const m = s.match(/^([^?#]+)([?#].*)?$/);
return String(m?.[1] || "");
};
if (h.startsWith("/wiki/")) {
const path = stripQueryHash(h);
const slugPart = path.slice("/wiki/".length).trim();
return slugPart ? decodeURIComponent(slugPart) : null;
}
const originPrefix = window.location.origin + "/wiki/";
if (h.startsWith(originPrefix)) {
const rest = stripQueryHash(h.slice(originPrefix.length));
const slugPart = rest.trim();
return slugPart ? decodeURIComponent(slugPart) : null;
}
return null;
};
const fetchPreview = async (targetSlug: string) => {
const key = targetSlug.trim();
if (!key.length) return;
const cached = previewCacheRef.current.get(key);
if (cached) {
setLinkPreviewData({ slug: key, title: cached.title, quote: cached.quote, status: "ready" });
return;
}
setLinkPreviewData((prev) => ({ slug: key, title: prev?.title || key, quote: null, status: "loading" }));
try {
const row = await fetchWikiBySlug(key);
if (!row) {
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
return;
}
const html = normalizeWikiContentToHtml(row.content ?? "");
let quote: string | null = null;
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const bq = doc.body.querySelector("blockquote");
const txt = String(bq?.textContent || "").trim();
quote = txt.length ? txt : null;
} catch {
quote = null;
}
const title = String(row.title || "").trim() || key;
previewCacheRef.current.set(key, { title, quote });
setLinkPreviewData({ slug: key, title, quote, status: "ready" });
} catch {
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
}
};
const showForAnchor = (a: HTMLAnchorElement) => {
const href = String(a.getAttribute("href") || "").trim();
const targetSlug = resolveInternalWikiSlug(href);
if (!targetSlug) return;
// Avoid previews on touch devices.
if (window.matchMedia && window.matchMedia("(hover: none)").matches) return;
const rect = a.getBoundingClientRect();
const width = 420;
const height = 320;
const margin = 12;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, window.innerHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
clearHideTimer();
setLinkPreview({ slug: targetSlug, top, left, width, height, visible: true });
void fetchPreview(targetSlug);
};
const onMouseOver = (evt: MouseEvent) => {
const target = evt.target as HTMLElement | null;
const a = target?.closest?.("a") as HTMLAnchorElement | null;
if (!a) return;
showForAnchor(a);
};
const onMouseOut = (evt: MouseEvent) => {
const target = evt.target as HTMLElement | null;
const related = evt.relatedTarget as HTMLElement | null;
const fromA = target?.closest?.("a");
if (!fromA) return;
if (related && related.closest?.(".uhm-wiki-link-preview")) return;
hideSoon();
};
const onKeyDown = (evt: KeyboardEvent) => {
if (evt.key === "Escape") {
clearHideTimer();
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
}
};
const onScroll = () => {
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
};
root.addEventListener("mouseover", onMouseOver);
root.addEventListener("mouseout", onMouseOut);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
root.removeEventListener("mouseover", onMouseOver);
root.removeEventListener("mouseout", onMouseOut);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onScroll);
clearHideTimer();
};
}, [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-[#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={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 === "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>
<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>
{linkPreview && linkPreview.visible ? (
<div
className="uhm-wiki-link-preview fixed z-[9999]"
style={{
top: linkPreview.top,
left: linkPreview.left,
width: linkPreview.width,
height: linkPreview.height,
}}
onMouseEnter={() => {
if (hidePreviewTimerRef.current != null) {
window.clearTimeout(hidePreviewTimerRef.current);
hidePreviewTimerRef.current = null;
}
}}
onMouseLeave={() => {
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
}}
>
<div className="h-full w-full overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-lg">
<div className="h-full w-full p-3 grid grid-rows-[auto_1fr] gap-2">
<div className="min-w-0">
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all">
/wiki/{linkPreview.slug}
</div>
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{linkPreviewData?.slug === linkPreview.slug
? linkPreviewData.status === "loading"
? "Loading..."
: linkPreviewData.status === "error"
? "Not found"
: linkPreviewData.title
: "Loading..."}
</div>
</div>
<div className="min-h-0 overflow-auto">
{linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? (
linkPreviewData.quote ? (
<div className="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words">
{linkPreviewData.quote}
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400">No resume.</div>
)
) : (
<div className="text-xs text-gray-500 dark:text-gray-400">Loading preview...</div>
)}
</div>
</div>
</div>
</div>
) : null}
<style jsx global>{`
.wiki-article {
line-height: 1.6;
font-size: 1em;
padding: 18px 20px;
}
.uhm-wiki-view.ql-editor {
height: auto;
overflow-y: visible;
}
.wiki-article p {
margin: 0 0 0.75em;
}
.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;
}
.wiki-article h2 {
font-size: 1.5em;
line-height: 1.25;
margin-top: 1.4em;
}
.wiki-article h3 {
font-size: 1.25em;
line-height: 1.3;
}
.wiki-article h4,
.wiki-article h5,
.wiki-article h6 {
font-size: 1.05em;
line-height: 1.35;
}
.wiki-article ul,
.wiki-article ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.wiki-article blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid #a2a9b1;
color: #202122;
}
.wiki-article pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid #a2a9b1;
border-radius: 10px;
background: #f8f9fa;
overflow: auto;
font-family: monospace;
}
.wiki-article img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.wiki-article a {
text-decoration: none;
}
.wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
color: #3366cc;
}
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
text-decoration: underline;
}
.wiki-article a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.wiki-article a:not([href]),
.wiki-article a[href=""],
.wiki-article a[href="__missing__"] {
color: #dc2626;
}
`}</style>
</div>
);
}
+6 -10
View File
@@ -1,6 +1,5 @@
export type StoredTokens = {
access_token: string;
refresh_token: string;
};
const LS_KEY = "uhm_auth_tokens_v1";
@@ -12,9 +11,9 @@ function safeParseTokens(raw: string | null): StoredTokens | null {
try {
const v = JSON.parse(raw) as Partial<StoredTokens>;
if (!v || typeof v !== "object") return null;
if (typeof v.access_token !== "string" || typeof v.refresh_token !== "string") return null;
if (!v.access_token.trim() || !v.refresh_token.trim()) return null;
return { access_token: v.access_token, refresh_token: v.refresh_token };
if (typeof v.access_token !== "string") return null;
if (!v.access_token.trim()) return null;
return { access_token: v.access_token };
} catch {
return null;
}
@@ -41,10 +40,6 @@ export function getAccessToken(): string | null {
return getStoredTokens()?.access_token ?? null;
}
export function getRefreshToken(): string | null {
return getStoredTokens()?.refresh_token ?? null;
}
export function clearStoredTokens(): void {
setStoredTokens(null);
}
@@ -64,6 +59,7 @@ export function extractTokensFromResponsePayload(payload: any): StoredTokens | n
tokenContainer?.accessToken ??
tokenContainer?.token ??
tokenContainer?.access ??
tokenContainer?.jwt ??
null;
const refresh =
@@ -71,8 +67,8 @@ export function extractTokensFromResponsePayload(payload: any): StoredTokens | n
tokenContainer?.refreshToken ??
tokenContainer?.refresh ??
null;
if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) {
return { access_token: access, refresh_token: refresh };
if (typeof access === "string" && access.trim()) {
return { access_token: access };
}
return null;
}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "@fullcalendar/core";
import { useModal } from "@/hooks/useModal";
import { Modal } from "@/components/ui/modal";
import { newId } from "@/uhm/lib/id";
import { newId } from "@/uhm/lib/utils/id";
interface CalendarEvent extends EventInput {
extendedProps: {
+14 -1
View File
@@ -8,7 +8,7 @@ import { fullDataUser } from "@/interface/admin";
import { UserMetaCardProps } from "@/interface/user";
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
import { useRouter } from "next/navigation";
import { ListIcon } from "@/icons";
import { ListIcon, ShootingStarIcon } from "@/icons";
export default function UserDropdown() {
const router = useRouter();
@@ -152,6 +152,19 @@ export default function UserDropdown() {
</span>
Nhà Sử Học
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/user/role-upgrade"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<span className="menu-item-icon">
<ShootingStarIcon />
</span>
Về chúng tôi
</DropdownItem>
</li>
{/* <li>
<DropdownItem
+9 -10
View File
@@ -1,16 +1,12 @@
import React, { ReactNode } from "react";
interface ButtonProps {
children: ReactNode; // Button text or content
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
size?: "sm" | "md"; // Button size
variant?: "primary" | "outline"; // Button variant
startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after the text
onClick?: () => void; // Click handler
disabled?: boolean; // Disabled state
className?: string; // Disabled state
type?: "button" | "submit" | "reset";
}
title?: string; // Title text
};
const Button: React.FC<ButtonProps> = ({
children,
@@ -18,10 +14,11 @@ const Button: React.FC<ButtonProps> = ({
variant = "primary",
startIcon,
endIcon,
onClick,
className = "",
disabled = false,
type = "button",
type = "button",
title,
...rest
}) => {
// Size Classes
const sizeClasses = {
@@ -44,8 +41,10 @@ const Button: React.FC<ButtonProps> = ({
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
onClick={onClick}
title={title}
disabled={disabled}
type={type}
{...rest}
>
{startIcon && <span className="flex items-center">{startIcon}</span>}
{children}
+1 -1
View File
@@ -88,7 +88,7 @@ export default function ChatbotWidget({
};
return (
<div className="fixed bottom-6 right-6 z-50">
<div className="fixed bottom-8 right-8 z-50">
{!isOpen && (
<button
onClick={() => setIsOpen(true)}
-37
View File
@@ -1,37 +0,0 @@
import axios from "axios";
import { API } from "../../api";
const axiosInstance = axios.create({
baseURL: "/",
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
axiosInstance.interceptors.response.use(
(response) => {
if (response.data && response.data.status === false) {
return handleRefreshToken(response);
}
return response;
},
async (error) => {
return Promise.reject(error);
}
);
async function handleRefreshToken(originalResponse: any) {
try {
const refreshRes = await axios.get(API.Auth.REFRESH, { withCredentials: true });
if (refreshRes.data && refreshRes.data.status !== false) {
return axiosInstance(originalResponse.config);
}
} catch (err) {
console.error("Refresh token failed", err);
}
return originalResponse;
}
export default axiosInstance;
+101 -80
View File
@@ -1,10 +1,9 @@
import axios from "axios"
import axios, { AxiosResponse } from "axios"
import { API_URL_ROOT } from "../../api"
import {
clearStoredTokens,
extractTokensFromResponsePayload,
getAccessToken,
getRefreshToken,
setStoredTokens,
} from "@/auth/tokenStore"
@@ -16,6 +15,12 @@ const api = axios.create({
withCredentials: true
})
// Dedicated instance for refresh to avoid interceptor loops and handle baseURL correctly.
const refreshApi = axios.create({
baseURL,
withCredentials: true
})
let isRefreshing = false
let queue: any[] = []
@@ -27,16 +32,17 @@ const processQueue = (error?: any) => {
queue = []
}
api.interceptors.request.use((config) => {
const token = getAccessToken()
api.interceptors.request.use((config: any) => {
if (config.skipAuth) return config
const token = config.authToken || getAccessToken()
if (token) {
const headers: any = config.headers || {}
// Do not override if caller set Authorization explicitly (case-insensitive).
const already =
typeof headers.get === "function"
? headers.get("Authorization")
: headers.Authorization || headers.authorization
if (!already) {
// If it's a retry after refresh, we MUST update the Authorization header with the fresh token.
// Otherwise, we only set it if not already present.
const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization")))
if (config._retry || !hasAuth) {
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
else headers.Authorization = `Bearer ${token}`
}
@@ -45,89 +51,104 @@ api.interceptors.request.use((config) => {
return config
})
function isAuthTokenExpiredMessage(message: string): boolean {
const normalized = message.trim().toLowerCase()
if (!normalized) return false
// Be specific: don't match general "unauthorized" or "access denied" which could be 403.
// Match only messages clearly indicating token expiration or invalidity.
return (
normalized.includes("invalid or expired jwt") ||
normalized.includes("jwt expired") ||
normalized.includes("token expired") ||
normalized.includes("invalid token") ||
normalized.includes("expired token") ||
normalized.includes("token is invalid") ||
normalized.includes("not authenticated")
)
}
api.interceptors.response.use(
(res) => {
async (res: AxiosResponse): Promise<AxiosResponse> => {
// Opportunistically persist tokens from signin/refresh responses.
const tokens = extractTokensFromResponsePayload(res?.data)
if (tokens) setStoredTokens(tokens)
// Handle backends that return 200 OK with status:false + expired token message.
const data = res.data
const originalRequest = res.config as any
const url = String(originalRequest?.url || "")
if (
data &&
data.status === false &&
isAuthTokenExpiredMessage(data.message || "") &&
!originalRequest._retry &&
!originalRequest.skipRefresh &&
!url.includes("/auth/")
) {
return performRefreshAndRetry(originalRequest)
}
return res
},
async (err) => {
const originalRequest = err.config
const originalRequest = err.config as any
const url = String(originalRequest?.url || "")
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: () => resolve(api(originalRequest)),
reject
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshToken = getRefreshToken()
const tryHeaderRefresh = async () => {
if (!refreshToken) return null
return axios.post(
`${baseURL}/auth/refresh`,
{},
{ headers: { Authorization: `Bearer ${refreshToken}` } }
)
}
const tryCookieRefresh = async () => {
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
}
let refreshRes: any = null
try {
refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh())
} catch (e: any) {
// If header-based refresh fails (wrong token type), fall back to cookie refresh.
if (refreshToken && e?.response?.status === 401) {
refreshRes = await tryCookieRefresh()
} else {
throw e
}
}
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
if (nextTokens) setStoredTokens(nextTokens)
// Some backends may return only a new access token; keep refresh token.
else {
const maybeAccess = (refreshRes?.data?.data?.access_token ??
refreshRes?.data?.access_token) as unknown
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
// Keep refresh token if we have one; otherwise rely on cookies.
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
}
}
processQueue()
return api(originalRequest)
} catch (refreshErr: any) {
processQueue(refreshErr)
// Only force logout when refresh token/session is truly invalid (401).
if (refreshErr?.response?.status === 401) {
clearStoredTokens()
window.location.href = "/signin"
}
return Promise.reject(refreshErr)
} finally {
isRefreshing = false
}
if (err.response?.status === 401 && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/")) {
return performRefreshAndRetry(originalRequest)
}
return Promise.reject(err)
}
)
async function performRefreshAndRetry(originalRequest: any): Promise<AxiosResponse> {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: () => resolve(api(originalRequest)),
reject
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const tryCookieRefresh = async () => {
return refreshApi.post("/auth/refresh", {})
}
let refreshRes: any = await tryCookieRefresh()
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
if (nextTokens) setStoredTokens(nextTokens)
// Some backends may return only a new access token; keep refresh token.
else {
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
}
}
processQueue()
return api(originalRequest)
} catch (refreshErr: any) {
processQueue(refreshErr)
// Only force logout when refresh token/session is truly invalid (401).
// CRITICAL: We only redirect if it's a 401, which means the HttpOnly cookie is missing or invalid.
if (refreshErr?.response?.status === 401) {
clearStoredTokens()
if (typeof window !== "undefined") {
window.location.href = "/signin"
}
}
return Promise.reject(refreshErr)
} finally {
isRefreshing = false
}
}
export default api
+6 -7
View File
@@ -5,7 +5,7 @@ import UserDropdown from "@/components/header/UserDropdown";
import { useSidebar } from "@/context/SidebarContext";
import Image from "next/image";
import Link from "next/link";
import React, { useState ,useEffect,useRef} from "react";
import React, { useState, useEffect, useRef } from "react";
const AppHeader: React.FC = () => {
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
@@ -156,21 +156,20 @@ const AppHeader: React.FC = () => {
</div>
</div>
<div
className={`${
isApplicationMenuOpen ? "flex" : "hidden"
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
className={`${isApplicationMenuOpen ? "flex" : "hidden"
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
>
<div className="flex items-center gap-2 2xsm:gap-3">
{/* <!-- Dark Mode Toggler --> */}
{/* <ThemeToggleButton /> */}
{/* <!-- Dark Mode Toggler --> */}
{/* <NotificationDropdown /> */}
{/* <NotificationDropdown /> */}
{/* <!-- Notification Menu Area --> */}
</div>
{/* <!-- User Area --> */}
<UserDropdown />
<UserDropdown />
</div>
</div>
</header>
+40 -29
View File
@@ -15,6 +15,7 @@ import {
PageIcon,
PieChartIcon,
PlugInIcon,
ShootingStarIcon,
TableIcon,
UserCircleIcon,
} from "../icons/index";
@@ -53,36 +54,47 @@ const ALL_NAV_ITEMS: NavItem[] = [
name: "Tài Khoản",
path: "/user/account",
},
];
const OTHERS_ITEMS: NavItem[] = [
// {
// icon: <PieChartIcon />,
// name: "Charts",
// subItems: [
// { name: "Line Chart", path: "/line-chart", pro: false },
// { name: "Bar Chart", path: "/bar-chart", pro: false },
// ],
// },
// {
// icon: <BoxCubeIcon />,
// name: "UI Elements",
// subItems: [
// { name: "Alerts", path: "/alerts", pro: false },
// { name: "Avatar", path: "/avatars", pro: false },
// { name: "Badge", path: "/badge", pro: false },
// { name: "Buttons", path: "/buttons", pro: false },
// { name: "Images", path: "/images", pro: false },
// { name: "Videos", path: "/videos", pro: false },
// ],
// },
// {
// icon: <PlugInIcon />,
// name: "Authentication",
// subItems: [
// { name: "Sign In", path: "/signin", pro: false },
// { name: "Sign Up", path: "/signup", pro: false },
// ],
// },
{
icon: <PieChartIcon />,
name: "Charts",
subItems: [
{ name: "Line Chart", path: "/line-chart", pro: false },
{ name: "Bar Chart", path: "/bar-chart", pro: false },
],
icon: <ShootingStarIcon />,
name: "Về Chúng Tôi",
path: "/user/about-us",
},
{
icon: <BoxCubeIcon />,
name: "UI Elements",
subItems: [
{ name: "Alerts", path: "/alerts", pro: false },
{ name: "Avatar", path: "/avatars", pro: false },
{ name: "Badge", path: "/badge", pro: false },
{ name: "Buttons", path: "/buttons", pro: false },
{ name: "Images", path: "/images", pro: false },
{ name: "Videos", path: "/videos", pro: false },
],
},
{
icon: <PlugInIcon />,
name: "Authentication",
subItems: [
{ name: "Sign In", path: "/signin", pro: false },
{ name: "Sign Up", path: "/signup", pro: false },
],
icon: <ShootingStarIcon />,
name: "Hỗ trợ",
path: "/user/quick-qa",
},
];
@@ -152,11 +164,10 @@ const AppSidebar: React.FC = () => {
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group uppercase ${
openSubmenu?.type === menuType && openSubmenu?.index === index
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
>
<span
className={
@@ -280,12 +291,12 @@ const AppSidebar: React.FC = () => {
</h2>
{renderMenuItems(ALL_NAV_ITEMS, "main")}
</div>
{/* <div>
<div>
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
</h2>
{renderMenuItems(OTHERS_ITEMS, "others")}
</div> */}
</div>
</div>
</nav>
</div>
+1 -2
View File
@@ -4,7 +4,6 @@ import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
export type AuthTokens = {
access_token: string;
refresh_token: string;
};
export type CurrentUser = {
@@ -21,7 +20,7 @@ export async function signIn(email: string, password: string): Promise<AuthToken
jsonRequestInit("POST", { email, password }),
{ skipAuth: true }
);
if (res?.access_token && res?.refresh_token) setStoredTokens(res);
if (res?.access_token) setStoredTokens(res);
return res;
}
+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`,
+17 -3
View File
@@ -4,11 +4,25 @@ import type { Entity } from "@/uhm/types/entities";
export type { Entity } from "@/uhm/types/entities";
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
export async function fetchEntities(query?: {
q?: string;
limit?: number;
cursor?: string;
projectId?: string;
}): Promise<Entity[]> {
const params = new URLSearchParams();
// API mới dùng `name` thay vì `q`.
if (query?.q) {
params.set("name", query.q);
if (query && "q" in query) {
params.set("name", String(query.q ?? ""));
}
if (query?.limit && Number.isFinite(query.limit)) {
params.set("limit", String(Math.trunc(query.limit)));
}
if (query?.cursor) {
params.set("cursor", query.cursor);
}
if (query?.projectId) {
params.set("project_id", query.projectId);
}
const suffix = params.toString();
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
+36 -3
View File
@@ -2,13 +2,13 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap";
import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
export type EntityGeometrySearchGeo = {
id: string;
geo_type: number;
type: string | null;
draw_geometry: unknown;
binding?: unknown;
time_start?: number | null;
@@ -27,6 +27,18 @@ export type SearchGeometriesByEntityNameResponse = {
next_cursor?: string;
};
type EntityGeometrySearchGeoRow = Omit<EntityGeometrySearchGeo, "type"> & {
geo_type: number;
};
type EntityGeometriesSearchItemRow = Omit<EntityGeometriesSearchItem, "geometries"> & {
geometries: EntityGeometrySearchGeoRow[];
};
type SearchGeometriesByEntityNameApiResponse = Omit<SearchGeometriesByEntityNameResponse, "items"> & {
items: EntityGeometriesSearchItemRow[];
};
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({
// API mới dùng snake_case
@@ -40,6 +52,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
query.set("time", String(params.time));
}
if (params.timeRange !== undefined) {
query.set("time_range", String(params.timeRange));
}
if (params.entity_id) {
query.set("entity_id", params.entity_id);
}
@@ -67,7 +83,24 @@ export async function searchGeometriesByEntityName(
params.set("limit", String(Math.trunc(options.limit)));
}
return requestJson<SearchGeometriesByEntityNameResponse>(`${API_ENDPOINTS.geometries}/entity?${params.toString()}`);
const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
`${API_ENDPOINTS.geometries}/entity?${params.toString()}`
);
return {
...response,
items: (response.items || []).map((item) => ({
...item,
geometries: (item.geometries || []).map((geometry) => ({
id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
draw_geometry: geometry.draw_geometry,
binding: geometry.binding,
time_start: geometry.time_start ?? null,
time_end: geometry.time_end ?? null,
})),
})),
};
}
type GeometryRow = {
+51 -150
View File
@@ -1,6 +1,5 @@
import type { ApiEnvelope } from "@/uhm/types/api";
import { API_ENDPOINTS } from "@/uhm/api/config";
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
import api from "@/config/config";
export class ApiError extends Error {
status: number;
@@ -16,8 +15,6 @@ export class ApiError extends Error {
}
}
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
type RequestJsonOptions = {
skipAuth?: boolean;
skipRefresh?: boolean;
@@ -29,7 +26,56 @@ export async function requestJson<T>(
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
return requestJsonInternal<T>(input, init, options);
const url = typeof input === "string" ? input : String(input);
const method = init?.method || "GET";
// Convert RequestInit.body to object if it's a JSON string.
let data = init?.body;
if (typeof data === "string" && data.length > 0) {
try {
data = JSON.parse(data);
} catch {
// Keep as string if not JSON.
}
}
try {
const response = await api.request({
url,
method,
data,
headers: init?.headers as any,
// Custom properties for our axios interceptor.
skipAuth: options?.skipAuth,
authToken: options?.authToken,
skipRefresh: options?.skipRefresh,
} as any);
const payload = response.data;
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
if (envelope) {
const isError = envelope.status === false || envelope.status === "error";
if (isError) {
const message = extractErrorMessage(payload, envelope) || "Request failed";
throw new ApiError(message, response.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
}
return (envelope.data ?? null) as T;
}
return payload as T;
} catch (err: any) {
if (err instanceof ApiError) throw err;
const status = err.response?.status || 0;
const payload = err.response?.data;
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
const message = extractErrorMessage(payload, envelope) || err.message || "Request failed";
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
throw new ApiError(message, status, body, errors);
}
}
export function jsonRequestInit(method: string, body: unknown): RequestInit {
@@ -40,74 +86,6 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit {
};
}
async function requestJsonInternal<T>(
input: RequestInfo | URL,
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
const nextInit = withAuthHeaders(init, options);
let res: Response;
try {
res = await fetch(input, nextInit);
} catch (err) {
// Browser "TypeError: Failed to fetch" typically means:
// - CORS blocked (common when using 127.0.0.1 instead of localhost in dev),
// - DNS/TLS/network error,
// - request blocked by the browser.
const origin = typeof window !== "undefined" ? window.location.origin : "<server>";
const url = typeof input === "string" ? input : String(input);
const details = { origin, url, apiBase: API_ENDPOINTS.projects.split("/projects")[0] };
throw new ApiError("Network error (failed to fetch)", 0, stringifyPayload(details));
}
// One-shot refresh + retry for protected endpoints.
if (
res.status === 401 &&
!options?.skipRefresh &&
!options?.skipAuth &&
typeof input === "string" &&
!String(input).includes("/auth/")
) {
const refreshed = await tryRefreshTokens();
if (refreshed) {
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
}
}
const payload = await parseJsonResponse(res);
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
if (!res.ok) {
const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`;
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
throw new ApiError(message, res.status, body, errors);
}
if (envelope) {
const isError =
envelope.status === false ||
envelope.status === "error";
if (isError) {
const message = extractErrorMessage(payload, envelope) || "Request failed";
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
}
return (envelope.data ?? null) as T;
}
return payload as T;
}
async function parseJsonResponse(res: Response): Promise<unknown> {
const text = await res.text();
if (!text.length) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
@@ -139,80 +117,3 @@ function stringifyPayload(payload: unknown): string {
return String(payload);
}
}
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
const baseInit: RequestInit = {
...init,
credentials: init?.credentials ?? "include",
};
const headers = new Headers(baseInit.headers || undefined);
const override = options?.authToken;
if (override) {
headers.set("Authorization", `Bearer ${override}`);
return { ...baseInit, headers };
}
if (options?.skipAuth) return baseInit;
const access = getAccessToken();
if (access) headers.set("Authorization", `Bearer ${access}`);
return { ...baseInit, headers };
}
let refreshInFlight: Promise<boolean> | null = null;
async function tryRefreshTokens(): Promise<boolean> {
// Single-flight refresh for concurrent 401s.
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const refreshToken = getRefreshToken();
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
let payload: unknown;
try {
payload = await requestJsonInternal<unknown>(
API_ENDPOINTS.authRefresh,
{ method: "POST" },
refreshToken
? { skipRefresh: true, authToken: refreshToken }
: { skipRefresh: true, skipAuth: true }
);
} catch (err) {
if (refreshToken && err instanceof ApiError && err.status === 401) {
payload = await requestJsonInternal<unknown>(
API_ENDPOINTS.authRefresh,
{ method: "POST" },
{ skipRefresh: true, skipAuth: true }
);
} else {
throw err;
}
}
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
if (next) {
setStoredTokens(next);
return true;
}
// Fallback: if server returns only access_token, keep existing refresh token (if any).
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
return true;
}
return false;
} catch {
return false;
}
})();
try {
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
@@ -1,44 +1,45 @@
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
import { toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type {
CreateCommitInput,
CreateSectionInput,
CreateProjectInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/uhm/types/sections";
Project,
ProjectCommit,
ProjectState,
ProjectSubmission,
} from "@/uhm/types/projects";
export type {
CreateCommitInput,
CreateSectionInput,
CreateProjectInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/uhm/types/sections";
Project,
ProjectCommit,
ProjectState,
ProjectSubmission,
} from "@/uhm/types/projects";
// Sections (API cũ) => Projects (API mới)
// Projects (API cũ) => Projects (API mới)
export async function fetchSections(): Promise<Section[]> {
export async function fetchProjects(): Promise<Project[]> {
// /users/current/project requires JWT.
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
return requestJson<Project[]>(API_ENDPOINTS.currentUserProjects);
}
export async function createSection(input: CreateSectionInput): Promise<Section> {
export async function createProject(input: CreateProjectInput): Promise<Project> {
// POST /projects
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
return requestJson<Project>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
}
export async function openSectionEditor(sectionId: string): Promise<EditorLoadResponse> {
export async function openSectionEditor(projectId: string): Promise<EditorLoadResponse> {
// API mới không có endpoint "editor". FE tự load:
// 1) Project details
// 2) Project commits (to get snapshot_json of latest commit)
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
if (pending) {
@@ -51,42 +52,43 @@ export async function openSectionEditor(sectionId: string): Promise<EditorLoadRe
);
}
const commits = await fetchSectionCommits(sectionId);
const commits = await fetchProjectCommits(projectId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const snapshot = headCommit?.snapshot_json ?? null;
const state: SectionState = {
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
};
return {
section: project,
project: project,
state,
commit: headCommit,
snapshot,
};
}
export async function createSectionCommit(
sectionId: string,
export async function createProjectCommit(
projectId: string,
input: CreateCommitInput
): Promise<{ commit: SectionCommit; state: SectionState }> {
): Promise<{ commit: ProjectCommit; state: ProjectState }> {
// POST /projects/{id}/commits
const commit = await requestJson<SectionCommit>(
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
const snapshot = toApiEditorSnapshot(input.snapshot);
const commit = await requestJson<ProjectCommit>(
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`,
jsonRequestInit("POST", {
snapshot_json: input.snapshot,
snapshot_json: snapshot,
edit_summary: input.edit_summary,
})
);
// Refresh project state (latest_commit_id may have moved).
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const state: SectionState = {
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: project.latest_commit_id ?? null,
locked_by: project.locked_by ?? null,
@@ -95,27 +97,27 @@ export async function createSectionCommit(
return { commit, state };
}
export async function fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
export async function fetchProjectCommits(projectId: string): Promise<ProjectCommit[]> {
return requestJson<ProjectCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`);
}
export async function restoreSectionCommit(
sectionId: string,
export async function restoreProjectCommit(
projectId: string,
input: RestoreCommitInput
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
): Promise<{ commit: ProjectCommit | null; state: ProjectState }> {
// POST /projects/{id}/commits/restore
await requestJson(
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits/restore`,
jsonRequestInit("POST", { commit_id: input.commit_id })
);
// Reload commits + project to determine new head commit.
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const commits = await fetchSectionCommits(sectionId);
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const commits = await fetchProjectCommits(projectId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const state: SectionState = {
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
@@ -124,20 +126,20 @@ export async function restoreSectionCommit(
return { commit: headCommit, state };
}
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
export async function submitSection(projectId: string, content: string): Promise<ProjectSubmission> {
// Submit latest commit of project
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const commitId = project.latest_commit_id;
if (!commitId) {
throw new Error("Project has no latest commit to submit");
}
return requestJson<SectionSubmission>(
return requestJson<ProjectSubmission>(
API_ENDPOINTS.submissions,
jsonRequestInit("POST", {
project_id: sectionId,
project_id: projectId,
commit_id: commitId,
content: "",
content: content,
})
);
}
+31 -8
View File
@@ -1,19 +1,25 @@
import api from "@/config/config";
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import { ApiError, requestJson } from "@/uhm/api/http";
export type Wiki = {
id: string;
project_id?: string;
title?: string;
slug?: string | null;
content?: string;
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[]> {
const keyword = title.trim();
if (!keyword.length) return [];
const params = new URLSearchParams({ title: keyword });
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
if (options?.cursor) params.set("cursor", options.cursor);
@@ -28,6 +34,18 @@ export async function fetchWikiById(id: string): Promise<Wiki> {
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
}
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
const value = String(slug || "").trim();
if (!value.length) return null;
try {
return await requestJson<Wiki>(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`);
} catch (err) {
// Treat "not found" as an empty result for search UX.
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
const value = String(slug || "").trim();
if (!value.length) return false;
@@ -38,13 +56,18 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
if (typeof payload === "boolean") return payload;
if (payload && typeof payload === "object") {
const anyPayload = payload as any;
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
const source = payload as Record<string, unknown>;
if (typeof source.exists === "boolean") return source.exists;
if (typeof source.exists === "number") return source.exists !== 0;
if (typeof source.is_exists === "boolean") return source.is_exists;
if (typeof source.is_exists === "number") return source.is_exists !== 0;
}
// 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;
};
-9
View File
@@ -1,9 +0,0 @@
"use client";
// FrontEndUser is the primary FE and follows BackEndGo cookie-based auth.
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
// This component remains as a no-op placeholder for any legacy imports.
export default function AuthPanel() {
return null;
}
+61 -427
View File
@@ -1,25 +1,30 @@
"use client";
import type { ReactNode } from "react";
import type { UndoAction } from "@/uhm/lib/useEditorState";
import { useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { ProjectPanel } from "./editor/ProjectPanel";
import { ToolsPanel } from "./editor/ToolsPanel";
import { CommitPanel } from "./editor/CommitPanel";
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
import { UndoListPanel } from "./editor/UndoListPanel";
import { SubmitModal } from "./editor/SubmitModal";
type Props = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
onSubmit: () => void;
onSubmit: (content: string) => void;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
sectionTitle: string;
sectionStatus: string;
projectStatus: string;
commitTitle: string;
commitNote: string;
onCommitTitleChange: (title: string) => void;
onCommitNoteChange: (note: string) => void;
commitCount: number;
hasHeadCommit: boolean;
headCommitId: string | null;
@@ -32,16 +37,6 @@ type Props = {
}>;
changesCount: number;
undoStack: UndoAction[];
createdEntities: Array<{
id: string;
name: string;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
width?: number;
};
@@ -56,11 +51,9 @@ export default function Editor({
isSaving,
isSubmitting,
sectionTitle,
sectionStatus,
projectStatus,
commitTitle,
commitNote,
onCommitTitleChange,
onCommitNoteChange,
commitCount,
hasHeadCommit,
headCommitId,
@@ -68,57 +61,24 @@ export default function Editor({
commits,
changesCount,
undoStack,
createdEntities,
createdGeometries,
width = 280,
}: Props) {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle");
} else {
setMode(newMode);
}
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
const [submitContent, setSubmitContent] = useState("");
const handleOpenSubmitModal = () => {
setSubmitContent("");
setIsSubmitModalOpen(true);
};
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
const handleConfirmSubmit = () => {
setIsSubmitModalOpen(false);
onSubmit(submitContent);
};
const formatCommitTitle = (commit: Props["commits"][number]) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
const modeButtonStyle = (btnMode: EditorMode) =>
({
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: mode === btnMode ? "#16a34a" : "#111827",
color: "white",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
minHeight: 34,
boxSizing: "border-box",
}) as const;
const primaryButtonStyle =
({
width: "100%",
padding: "8px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontWeight: 850,
fontSize: 12,
}) as const;
const handleCancelSubmit = () => {
setIsSubmitModalOpen(false);
};
return (
<div
@@ -133,77 +93,19 @@ export default function Editor({
}}
>
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
<Panel title="Project" defaultOpen>
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
<div style={{ marginTop: 6 }}>
Status: <span style={{ color: "#e2e8f0" }}>{sectionStatus}</span>
</div>
<div style={{ marginTop: 6 }}>
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
</div>
<div style={{ marginTop: 6 }}>
{latestCommitLabel ? (
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
) : (
<span style={{ color: "#94a3b8" }}>Chưa head commit</span>
)}
</div>
</div>
</Panel>
<ProjectPanel
sectionTitle={sectionTitle}
projectStatus={projectStatus}
commitCount={commitCount}
latestCommitLabel={latestCommitLabel}
/>
<Panel title="Tools" defaultOpen>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
Select
</button>
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
Draw
</button>
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
Point
</button>
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
Line
</button>
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
Path
</button>
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
Circle
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
</div>
<ModeHint mode={mode} />
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
style={{
...modeButtonStyle("idle"),
background: "#111827",
}}
onClick={() => setMode("idle")}
title="Tắt tool hiện tại"
>
Idle
</button>
<button
style={{
...modeButtonStyle("idle"),
background: "#334155",
}}
onClick={onUndo}
title="Undo thao tác gần nhất"
>
Undo
</button>
</div>
</Panel>
<ToolsPanel
mode={mode}
setMode={setMode}
onUndo={onUndo}
/>
{entityStatus ? (
<div
@@ -223,302 +125,34 @@ export default function Editor({
) : null}
</div>
<Panel title="Commit" defaultOpen>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Commit title"
disabled={isSaving || isSubmitting}
style={textInputStyle}
/>
<textarea
value={commitNote}
onChange={(event) => onCommitNoteChange(event.target.value)}
placeholder="Commit note"
disabled={isSaving || isSubmitting}
rows={3}
style={textAreaStyle}
/>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
opacity: changesCount <= 0 ? 0.75 : 1,
}}
onClick={onCommit}
disabled={isSaving || isSubmitting || changesCount <= 0}
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
>
Commit ({changesCount})
</button>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={onSubmit}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
</Panel>
<CommitPanel
commitTitle={commitTitle}
onCommitTitleChange={onCommitTitleChange}
isSaving={isSaving}
isSubmitting={isSubmitting}
changesCount={changesCount}
onCommit={onCommit}
hasHeadCommit={hasHeadCommit}
handleOpenSubmitModal={handleOpenSubmitModal}
/>
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{commits.slice(0, 8).map((commit) => {
const isHead = Boolean(headCommitId && commit.id === headCommitId);
return (
<li
key={commit.id}
style={{
padding: "8px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
display: "flex",
flexDirection: "row"
}}
>
<div style={{flex:1}}>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 750,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: 3, color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
</div>
<CommitHistoryPanel
commits={commits}
headCommitId={headCommitId}
onRestoreCommit={onRestoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
/>
<button
style={{
marginTop: 6,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #334155",
background: isHead ? "#0b1220" : "#334155",
color: "white",
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
opacity: isHead ? 0.65 : 1,
fontWeight: 800,
fontSize: 12,
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting || isHead}
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
>
Restore
</button>
</li>
);
})}
</ul>
)}
</Panel>
<UndoListPanel undoStack={undoStack} />
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</Panel>
<Panel title="This Session" defaultOpen={false}>
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
title={entity.id}
>
{entity.name}
</li>
))}
</ul>
)}
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
Geometries mới chưa commit ({createdGeometries.length})
</div>
{createdGeometries.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa geometry mới chờ commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
>
#{geometry.id} [{geometry.geometryType}]{" "}
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</Panel>
<SubmitModal
isSubmitModalOpen={isSubmitModalOpen}
submitContent={submitContent}
setSubmitContent={setSubmitContent}
handleCancelSubmit={handleCancelSubmit}
handleConfirmSubmit={handleConfirmSubmit}
/>
</div>
);
}
const textInputStyle = {
width: "100%",
marginTop: 0,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
} as const;
const textAreaStyle = {
...textInputStyle,
marginTop: 8,
resize: "vertical",
fontFamily: "inherit",
} as const;
function Panel({
title,
badge,
defaultOpen,
children,
}: {
title: string;
badge?: string | null;
defaultOpen?: boolean;
children: ReactNode;
}) {
return (
<details
open={Boolean(defaultOpen)}
style={{
marginTop: 10,
padding: 10,
background: "#111827",
borderRadius: 8,
border: "1px solid #1f2937",
}}
>
<summary
style={{
cursor: "pointer",
listStyle: "none",
fontWeight: 900,
fontSize: 13,
color: "white",
userSelect: "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<span>{title}</span>
{badge ? (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
border: "1px solid #334155",
background: "#0b1220",
color: "#cbd5e1",
fontSize: 12,
fontWeight: 850,
flex: "0 0 auto",
}}
>
{badge}
</span>
) : null}
</summary>
<div style={{ marginTop: 10 }}>{children}</div>
</details>
);
}
function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
);
}
if (mode === "add-circle") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
);
}
if (mode === "add-point") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 điểm trên bản đ đ đt đa điểm.
</div>
)
}
if (mode === "select") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 hình, đưng, điểm trên bản đ đ xem chi tiết.
</div>
)
}
if (mode === "draw") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn các điểm trên bản đ đ vẽ hình, ENTER đ kết thúc, ESC đ hủy.
</div>
)
}
return null;
}
function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
case "snapshot_entities":
case "snapshot_wikis":
case "snapshot_entity_wiki":
return action.label;
default:
return "Tác vụ";
}
}
+184 -1608
View File
File diff suppressed because it is too large Load Diff
-619
View File
@@ -1,619 +0,0 @@
"use client";
import { useEffect, useMemo, useState, type ComponentProps } from "react";
import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button";
import Label from "@/components/form/Label";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { newId } from "@/uhm/lib/id";
import type ReactQuill from "react-quill-new";
import { checkWikiSlugExists } from "@/uhm/api/wikis";
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
ssr: false,
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
});
type Props = {
projectId: string;
wikis: WikiSnapshot[];
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
autoOpen?: boolean;
requestedActiveId?: string | null;
};
function clampTitle(title: string) {
const t = title.trim();
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
const [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
const [wikiTitle, setWikiTitle] = useState("");
const [wikiSlug, setWikiSlug] = useState("");
const [wikiDocHtml, setWikiDocHtml] = useState("");
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createSlug, setCreateSlug] = useState("");
const [createSlugTouched, setCreateSlugTouched] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
useEffect(() => {
if (!autoOpen) return;
// open once on mount
setOpen(true);
}, [autoOpen]);
useEffect(() => {
if (!requestedActiveId) return;
if (wikis.some((w) => w.id === requestedActiveId)) {
setActiveId(requestedActiveId);
}
}, [requestedActiveId, wikis]);
// keep editor content in sync when switching wiki
useEffect(() => {
if (!open) return;
setWikiTitle(activeWiki?.title || "");
setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : "");
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
setWikiSaveError(null);
}, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]);
const ensureActive = () => {
if (activeId && wikis.some((w) => w.id === activeId)) return;
setActiveId(wikis[0]?.id || null);
};
useEffect(() => {
ensureActive();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wikis.length]);
const openEditor = () => {
if (!wikis.length) {
const id = newId();
const seed: WikiSnapshot = {
id,
source: "inline",
operation: "create",
title: "Untitled wiki",
slug: null,
doc: "",
updated_at: new Date().toISOString(),
};
setWikis((prev) => [seed, ...prev]);
setActiveId(id);
}
setOpen(true);
};
const createWikiAndOpen = (title?: string, slug?: string | null) => {
const id = newId();
const seedTitle = clampTitle(title || "Untitled wiki");
const seed: WikiSnapshot = {
id,
source: "inline",
operation: "create",
title: seedTitle,
slug: slug ?? null,
doc: "",
updated_at: new Date().toISOString(),
};
setWikis((prev) => [seed, ...prev]);
setActiveId(id);
setOpen(true);
};
const handleCreateWikiFromPanel = async () => {
const title = clampTitle(createTitle);
const slug = normalizeWikiSlugInput(createSlug);
if (!slug) {
setCreateError("Slug la bat buoc. Hay thu mot slug khac.");
return;
}
setIsCheckingCreateSlug(true);
setCreateError(null);
try {
const exists = await checkWikiSlugExists(slug);
if (exists) {
setCreateError("Slug da ton tai. Hay thu slug khac.");
return;
}
createWikiAndOpen(title, slug);
setCreateTitle("");
setCreateSlug("");
setCreateSlugTouched(false);
setIsCreateOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
setCreateError(msg);
} finally {
setIsCheckingCreateSlug(false);
}
};
const removeWiki = (id: string) => {
setWikis((prev) => prev.filter((w) => w.id !== id));
if (activeId === id) setActiveId(null);
};
const saveWiki = async () => {
if (!activeId) return;
const payload = wikiDocHtml;
const nextTitle = clampTitle(wikiTitle);
const nextSlug = normalizeWikiSlugInput(wikiSlug);
const current = wikis.find((w) => w.id === activeId) || null;
// Check uniqueness only when creating a brand-new wiki.
if (current?.operation === "create" && nextSlug) {
try {
const exists = await checkWikiSlugExists(nextSlug);
if (exists) {
setWikiSaveError("Slug da ton tai. Hay thu slug khac.");
return;
}
} catch (err) {
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
setWikiSaveError(msg);
return;
}
}
setWikiSaveError(null);
setWikis((prev) =>
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source,
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
slug: nextSlug,
doc: payload,
updated_at: new Date().toISOString(),
}
)
);
setOpen(false);
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Wiki" : "Thu gon panel Wiki"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : wikis.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
{wikis.slice(0, 8).map((w) => (
<div
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
}}
>
<button
type="button"
onClick={() => {
setActiveId(w.id);
setOpen(true);
}}
style={{
flex: 1,
textAlign: "left",
border: "none",
background: "transparent",
color: "#e5e7eb",
cursor: "pointer",
fontSize: "12px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={w.title}
>
{w.title}
</button>
<button
type="button"
onClick={() => removeWiki(w.id)}
style={{
border: "none",
background: "#111827",
color: "#fca5a5",
cursor: "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
}}
title="Remove"
>
Del
</button>
</div>
))}
{wikis.length > 8 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more</div>
) : null}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
No wiki yet for this project.
</div>
)}
{collapsed ? null : (
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={() =>
setIsCreateOpen((v) => {
const next = !v;
if (next) {
setCreateError(null);
setIsCheckingCreateSlug(false);
setCreateSlugTouched(false);
}
return next;
})
}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => {
const nextTitle = e.target.value;
setCreateTitle(nextTitle);
setCreateError(null);
if (!createSlugTouched) {
setCreateSlug(slugifyWikiTitle(nextTitle));
}
}}
placeholder="Tieu de wiki"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<input
value={createSlug}
onChange={(e) => {
setCreateSlugTouched(true);
setCreateSlug(e.target.value);
setCreateError(null);
}}
placeholder="Slug"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<button
type="button"
onClick={handleCreateWikiFromPanel}
disabled={isCheckingCreateSlug}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
opacity: isCheckingCreateSlug ? 0.7 : 1,
}}
>
Tạo wiki mới
</button>
{createError ? (
<div style={{ color: "#fca5a5", fontSize: 12 }}>
{createError}
</div>
) : null}
</>
) : null}
</div>
)}
<Modal
isOpen={open}
onClose={() => setOpen(false)}
showCloseButton={false}
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
className="max-w-[1100px] m-4 [&>button]:hidden"
>
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
Save
</Button>
</div>
</div>
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
<div className="flex flex-col gap-2">
{wikis.map((w) => (
<button
key={w.id}
type="button"
onClick={() => setActiveId(w.id)}
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
w.id === activeId
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
}`}
title={w.title}
>
<div className="font-medium truncate">{w.title}</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
</button>
))}
<Button size="sm" variant="outline" onClick={openEditor}>
+ New wiki
</Button>
</div>
</div>
<div className="lg:col-span-3">
<div className="grid grid-cols-1 gap-3">
<div>
<Label>Title</Label>
<input
value={wikiTitle}
onChange={(e) => setWikiTitle(e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
placeholder="Wiki title"
disabled={!activeId}
/>
</div>
<div>
<Label>Slug</Label>
<input
value={wikiSlug}
onChange={(e) => setWikiSlug(e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
placeholder="wiki-slug"
disabled={!activeId}
/>
</div>
{wikiSaveError ? (
<div className="text-xs text-red-600 dark:text-red-300">
{wikiSaveError}
</div>
) : null}
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
<ReactQuillEditor
theme="snow"
value={wikiDocHtml}
onChange={(content: string) => setWikiDocHtml(content)}
modules={QUILL_MODULES}
className="min-h-[320px]"
placeholder="Nhap noi dung wiki..."
readOnly={!activeId}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
Stored in snapshot_json on commit. This page does not write to DB yet.
</div>
</div>
</Modal>
</div>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
const QUILL_MODULES = {
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
["blockquote", "code-block"],
["link", "image"],
["clean"],
],
};
function normalizeWikiDocForQuill(doc: string | null): string {
const raw = (doc || "").trim();
if (!raw.length) return "";
// New format (Quill): HTML string.
if (raw[0] === "<") return raw;
// Legacy format (Tiptap): JSON string.
if (raw[0] === "{") {
try {
const json: unknown = JSON.parse(raw);
const text = tiptapJsonToPlainText(json).trim();
if (!text.length) return "";
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
} catch {
// fall through
}
}
// Unknown plaintext: treat as plain text.
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
}
function normalizeWikiSlugInput(raw: string): string | null {
const s = raw.trim();
return s.length ? s : null;
}
function slugifyWikiTitle(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function tiptapJsonToPlainText(node: unknown): string {
if (node == null) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
if (isRecord(node)) {
if (node.type === "text" && typeof node.text === "string") return node.text;
if (node.type === "hardBreak") return "\n";
if ("content" in node) return tiptapJsonToPlainText(node.content);
}
return "";
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
@@ -5,13 +5,16 @@ import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
} from "@/uhm/lib/backgroundLayers";
} from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
type Props = {
visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
geometryVisibility?: Record<string, boolean>;
onToggleGeometryType?: (typeKey: string) => void;
topContent?: ReactNode;
width?: number;
};
@@ -21,6 +24,8 @@ export default function BackgroundLayersPanel({
onToggleLayer,
onShowAll,
onHideAll,
geometryVisibility,
onToggleGeometryType,
topContent,
width = 240,
}: Props) {
@@ -69,6 +74,45 @@ export default function BackgroundLayersPanel({
);
})}
</div>
{geometryVisibility && onToggleGeometryType ? (
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => onToggleGeometryType(typeKey)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
) : null}
</aside>
);
}
@@ -0,0 +1,89 @@
import { Panel } from "./Panel";
type Commit = {
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
};
type CommitHistoryPanelProps = {
commits: Commit[];
headCommitId: string | null;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
};
export function CommitHistoryPanel({
commits,
headCommitId,
onRestoreCommit,
isSaving,
isSubmitting,
}: CommitHistoryPanelProps) {
const formatCommitTitle = (commit: Commit) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
return (
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{commits.slice(0, 8).map((commit) => {
const isHead = Boolean(headCommitId && commit.id === headCommitId);
return (
<li
key={commit.id}
style={{
padding: "8px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
display: "flex",
flexDirection: "row"
}}
>
<div style={{flex:1}}>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 750,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: 3, color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
</div>
<button
style={{
marginTop: 6,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #334155",
background: isHead ? "#0b1220" : "#334155",
color: "white",
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
opacity: isHead ? 0.65 : 1,
fontWeight: 800,
fontSize: 12,
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting || isHead}
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
>
Restore
</button>
</li>
);
})}
</ul>
)}
</Panel>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Panel } from "./Panel";
type CommitPanelProps = {
commitTitle: string;
onCommitTitleChange: (title: string) => void;
isSaving: boolean;
isSubmitting: boolean;
changesCount: number;
onCommit: () => void;
hasHeadCommit: boolean;
handleOpenSubmitModal: () => void;
};
export function CommitPanel({
commitTitle,
onCommitTitleChange,
isSaving,
isSubmitting,
changesCount,
onCommit,
hasHeadCommit,
handleOpenSubmitModal,
}: CommitPanelProps) {
const primaryButtonStyle = {
width: "100%",
padding: "8px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontWeight: 850,
fontSize: 12,
} as const;
const textInputStyle = {
width: "100%",
marginTop: 0,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
} as const;
return (
<Panel title="Commit" defaultOpen>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Edit Summary (Commit Title)"
disabled={isSaving || isSubmitting}
style={textInputStyle}
/>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
opacity: changesCount <= 0 ? 0.75 : 1,
}}
onClick={onCommit}
disabled={isSaving || isSubmitting || changesCount <= 0}
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
>
Commit ({changesCount})
</button>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={handleOpenSubmitModal}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
</Panel>
);
}
@@ -1,12 +1,21 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Entity } from "@/uhm/types/entities";
import { useMemo, useState } from "react";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import NewBadge from "@/uhm/components/editor/NewBadge";
type EntityChoice = { id: string; name: string };
type WikiChoice = { id: string; title: string; operation?: string };
type EntityChoice = { id: string; name: string; isNew?: boolean };
type WikiChoice = { id: string; title: string; isNew?: boolean };
type BindingRow = {
entityId: string;
entityName: string;
entityIsNew: boolean;
wikiId: string;
wikiTitle: string;
wikiIsNew: boolean;
linkIsNew: boolean;
};
type Props = {
entities: EntityChoice[];
@@ -29,7 +38,11 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
() =>
(wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => ({ id: w.id, title: wikiTitle(w), operation: w.operation })),
.map((w) => ({
id: w.id,
title: wikiTitle(w),
isNew: w.source === "inline" && w.operation === "create",
})),
[wikis]
);
@@ -39,17 +52,6 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
return cleaned;
}, [entities]);
// Don't auto-select entity. The user must explicitly pick one.
// Only clear the selection if the currently selected entity is no longer available.
useEffect(() => {
if (!activeEntityId) return;
const stillExists = entityChoices.some((e) => e.id === activeEntityId);
if (!stillExists) {
setActiveEntityId("");
setActiveWikiId("");
}
}, [activeEntityId, entityChoices]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
for (const l of links || []) {
@@ -60,6 +62,41 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
return set;
}, [activeEntityId, links]);
const activeBindingRows = useMemo<BindingRow[]>(() => {
const byKey = new Map<string, EntityWikiLinkSnapshot>();
for (const link of links || []) {
const entityId = String(link?.entity_id || "").trim();
const wikiId = String(link?.wiki_id || "").trim();
if (!entityId || !wikiId) continue;
if (link.operation === "delete") continue;
byKey.set(`${entityId}::${wikiId}`, link);
}
const rows = Array.from(byKey.values()).map((link) => {
const entityId = String(link.entity_id);
const wikiId = String(link.wiki_id);
const entity = entityChoices.find((item) => item.id === entityId) || null;
const wiki = wikiChoices.find((item) => item.id === wikiId) || null;
return {
entityId,
entityName: entity?.name || entityId,
entityIsNew: Boolean(entity?.isNew),
wikiId,
wikiTitle: wiki?.title || wikiId,
wikiIsNew: Boolean(wiki?.isNew),
linkIsNew: link.operation === "binding",
};
});
rows.sort((a, b) => {
if (a.linkIsNew !== b.linkIsNew) return a.linkIsNew ? -1 : 1;
const entityCompare = a.entityName.localeCompare(b.entityName);
if (entityCompare !== 0) return entityCompare;
return a.wikiTitle.localeCompare(b.wikiTitle);
});
return rows;
}, [entityChoices, links, wikiChoices]);
const toggle = (wikiId: string) => {
if (!activeEntityId) return;
const id = String(wikiId || "").trim();
@@ -81,6 +118,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
const activeEntityChoice = activeEntityId ? entityChoices.find((e) => e.id === activeEntityId) || null : null;
return (
<div
@@ -94,7 +132,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity Wiki</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{activeBindingRows.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
@@ -144,6 +182,13 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
</option>
))}
</select>
{activeEntityId ? (
<ActiveSelectionLabel
label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/>
) : null}
</div>
<div>
@@ -175,6 +220,13 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
</option>
))}
</select>
{activeWikiChoice ? (
<ActiveSelectionLabel
label={activeWikiChoice.title}
id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/>
) : null}
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
@@ -208,9 +260,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px" }}>
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).slice(0, 8).map((id) => {
{Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
@@ -228,17 +280,19 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
title={id}
>
<div style={{ minWidth: 0 }}>
<div
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</span>
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
@@ -264,9 +318,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
</div>
);
})}
{activeLinks.size > 8 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more</div>
) : null}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
@@ -275,12 +327,110 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
)}
</div>
</div>
<div
style={{
borderTop: "1px solid #1f2937",
paddingTop: 8,
display: "grid",
gap: 6,
}}
>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
All bindings ({activeBindingRows.length})
</div>
{activeBindingRows.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
{activeBindingRows.map((row) => (
<div
key={`${row.entityId}::${row.wikiId}`}
style={{
padding: 8,
borderRadius: 6,
border: row.linkIsNew ? "1px solid rgba(45, 212, 191, 0.55)" : "1px solid #1f2937",
background: row.linkIsNew ? "rgba(20, 184, 166, 0.12)" : "#111827",
display: "grid",
gap: 5,
}}
title={`${row.entityId}${row.wikiId}`}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
fontWeight: 800,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityName}
</span>
{row.entityIsNew ? <NewBadge title="Entity mới trong phiên này" /> : null}
{row.linkIsNew ? <NewBadge title="Binding mới trong phiên này" /> : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#93c5fd", fontSize: 11, flex: "0 0 auto" }}>Wiki</span>
<span
style={{
color: "#cbd5e1",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.wikiTitle}
</span>
{row.wikiIsNew ? <NewBadge title="Wiki mới trong phiên này" /> : null}
</div>
<div
style={{
color: "#64748b",
fontSize: 11,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityId} {row.wikiId}
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No entity-wiki binding yet.</div>
)}
</div>
</div>
)}
</div>
);
}
function ActiveSelectionLabel({
label,
id,
isNew,
}: {
label: string;
id: string;
isNew?: boolean;
}) {
return (
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#cbd5e1", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</span>
{isNew ? <NewBadge /> : null}
</div>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -1,10 +1,12 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import NewBadge from "@/uhm/components/editor/NewBadge";
type GeometryChoice = {
id: string;
label?: string;
isNew?: boolean;
};
type Props = {
@@ -12,6 +14,7 @@ type Props = {
selectedGeometryId: string | null;
selectedGeometryBindingIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void;
statusText?: string | null;
bindingFilterEnabled: boolean;
onBindingFilterEnabledChange: (next: boolean) => void;
@@ -22,26 +25,47 @@ export default function GeometryBindingPanel({
selectedGeometryId,
selectedGeometryBindingIds,
onToggleBindGeometryForSelectedGeometry,
onFocusGeometry,
statusText,
bindingFilterEnabled,
onBindingFilterEnabledChange,
}: Props) {
const canBindToggle =
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false);
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim() }));
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned;
}, [geometries]);
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
const selectedGeometry = useMemo(() => {
if (!selectedGeometryId) return null;
return rows.find((g) => g.id === selectedGeometryId) || null;
}, [rows, selectedGeometryId]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== selectedGeometryId)
.sort((a, b) => {
const aBound = bindingSet.has(a.id);
const bBound = bindingSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [bindingSet, rows, selectedGeometryId]);
const visibleRows = rows.slice(0, 12);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onFocusGeometry?.(geometryId);
};
return (
<div
@@ -100,10 +124,67 @@ export default function GeometryBindingPanel({
</div>
</div>
{collapsed ? null : selectedGeometry ? (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
}}
title={selectedGeometry.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
>
<div
style={{
fontSize: 10,
color: "#93c5fd",
fontWeight: 900,
textTransform: "uppercase",
lineHeight: 1,
marginBottom: 5,
}}
>
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.label || selectedGeometry.id}
</span>
{selectedGeometry.isNew ? <NewBadge /> : null}
</div>
<div
style={{
marginTop: 3,
fontSize: "11px",
color: "#94a3b8",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.id}
</div>
</div>
) : null}
{collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows
.filter((g) => g.id !== selectedGeometryId)
.map((g) => {
const isBound = bindingSet.has(g.id);
return (
@@ -112,27 +193,43 @@ export default function GeometryBindingPanel({
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75,
}}
title={g.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => onFocusGeometry?.(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "12px",
color: "#e5e7eb",
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
display: "flex",
alignItems: "center",
gap: 6,
minWidth: 0,
}}
>
{g.label || g.id}
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{g.label || g.id}
</span>
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null}
</div>
<div
style={{
@@ -151,7 +248,10 @@ export default function GeometryBindingPanel({
<button
type="button"
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() => onToggleBindGeometryForSelectedGeometry!(g.id, !isBound)}
onClick={(event) => {
event.stopPropagation();
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
}}
style={{
display: "inline-flex",
alignItems: "center",
@@ -176,11 +276,7 @@ export default function GeometryBindingPanel({
</div>
);
})}
{rows.length > visibleRows.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
+{rows.length - visibleRows.length} more
</div>
) : null}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
@@ -197,6 +293,24 @@ export default function GeometryBindingPanel({
);
}
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+47
View File
@@ -0,0 +1,47 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
);
}
if (mode === "add-circle") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
);
}
if (mode === "add-point") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 điểm trên bản đ đ đt đa điểm.
</div>
)
}
if (mode === "select") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 hình, đưng, điểm trên bản đ đ xem chi tiết.
</div>
)
}
if (mode === "draw") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn các điểm trên bản đ đ vẽ hình, ENTER đ kết thúc, ESC đ hủy.
</div>
)
}
if (mode === "replay") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang trong chế đ trình diễn diễn biến kịch bản.
</div>
)
}
return null;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { CSSProperties } from "react";
type Props = {
title?: string;
};
export default function NewBadge({ title = "Created in this session and not committed yet" }: Props) {
return (
<span style={badgeStyle} title={title}>
new
</span>
);
}
const badgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.55)",
background: "rgba(20, 184, 166, 0.16)",
color: "#5eead4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
+62
View File
@@ -0,0 +1,62 @@
import type { ReactNode } from "react";
type PanelProps = {
title: string;
badge?: string | null;
defaultOpen?: boolean;
children: ReactNode;
};
export function Panel({
title,
badge,
defaultOpen,
children,
}: PanelProps) {
return (
<details
open={Boolean(defaultOpen)}
style={{
marginTop: 10,
padding: 10,
background: "#111827",
borderRadius: 8,
border: "1px solid #1f2937",
}}
>
<summary
style={{
cursor: "pointer",
listStyle: "none",
fontWeight: 900,
fontSize: 13,
color: "white",
userSelect: "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<span>{title}</span>
{badge ? (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
border: "1px solid #334155",
background: "#0b1220",
color: "#cbd5e1",
fontSize: 12,
fontWeight: 850,
flex: "0 0 auto",
}}
>
{badge}
</span>
) : null}
</summary>
<div style={{ marginTop: 10 }}>{children}</div>
</details>
);
}
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useMemo, useState, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
import NewBadge from "@/uhm/components/editor/NewBadge";
type Props = {
entityRefs: EntitySnapshot[];
@@ -38,6 +39,22 @@ export default function ProjectEntityRefsPanel({
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const selectedEntityIdSet = useMemo(
() => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds]
);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
const aBound = selectedEntityIdSet.has(String(a.id));
const bBound = selectedEntityIdSet.has(String(b.id));
if (aBound !== bBound) return aBound ? -1 : 1;
const aLabel = String(a.name || a.id || "");
const bLabel = String(b.name || b.id || "");
return aLabel.localeCompare(bLabel);
});
return rows;
}, [entityRefs, selectedEntityIdSet]);
const activeEntity = useMemo(
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
@@ -46,18 +63,11 @@ export default function ProjectEntityRefsPanel({
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
useEffect(() => {
if (!activeEntityId) return;
if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) {
setActiveEntityId(null);
}
}, [activeEntityId, entityRefs]);
useEffect(() => {
if (!activeEntity) return;
setEditName(typeof activeEntity.name === "string" ? activeEntity.name : "");
setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description));
}, [activeEntity?.description, activeEntity?.id, activeEntity?.name]);
const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description));
};
return (
<div
@@ -96,81 +106,94 @@ export default function ProjectEntityRefsPanel({
</div>
</div>
{collapsed ? null : entityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
{entityRefs.slice(0, 8).map((e) => (
<div
key={e.id}
style={{
padding: "8px",
borderRadius: "6px",
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937",
background: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<button
type="button"
onClick={() => setActiveEntityId(String(e.id))}
title="Chon de sua"
{collapsed ? null : sortedEntityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{sortedEntityRefs.map((e) => {
const entityId = String(e.id);
const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
const isActive = activeEntityId === entityId;
return (
<div
key={e.id}
style={{
flex: 1,
minWidth: 0,
textAlign: "left",
border: "none",
background: "transparent",
padding: 0,
cursor: canEditEntity ? "pointer" : "default",
padding: "8px",
borderRadius: "6px",
border: isActive
? "1px solid #2563eb"
: isBoundToSelectedGeometry
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBoundToSelectedGeometry ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
disabled={!canEditEntity}
>
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button>
{canBindToggle ? (
<button
type="button"
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
String(e.id),
!selectedGeometryEntityIds!.includes(String(e.id))
)
}
onClick={() => openEntityEditor(e)}
title="Chon de sua"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
flex: 1,
minWidth: 0,
textAlign: "left",
border: "none",
background: "transparent",
padding: 0,
cursor: canEditEntity ? "pointer" : "default",
}}
aria-label={
selectedGeometryEntityIds!.includes(String(e.id))
? `Unbind entity ${String(e.id)} from selected geometry`
: `Bind entity ${String(e.id)} to selected geometry`
}
disabled={!canEditEntity}
>
{selectedGeometryEntityIds!.includes(String(e.id)) ? (
<UnlockIcon />
) : (
<LockIcon />
)}
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</span>
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
{isNewEntityRef(e) ? <NewBadge /> : null}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button>
) : null}
</div>
))}
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more</div> : null}
{canBindToggle ? (
<button
type="button"
title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
entityId,
!isBoundToSelectedGeometry
)
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBoundToSelectedGeometry
? `Unbind entity ${entityId} from selected geometry`
: `Bind entity ${entityId} to selected geometry`
}
>
{isBoundToSelectedGeometry ? (
<UnlockIcon />
) : (
<LockIcon />
)}
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
@@ -189,8 +212,11 @@ export default function ProjectEntityRefsPanel({
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
Sua entity
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
Sua entity
</span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div>
<button
type="button"
@@ -344,6 +370,10 @@ export default function ProjectEntityRefsPanel({
);
}
function isNewEntityRef(entity: EntitySnapshot | null | undefined): boolean {
return entity?.source === "inline" && entity?.operation === "create";
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
@@ -354,6 +384,24 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px",
};
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -0,0 +1,36 @@
import { Panel } from "./Panel";
type ProjectPanelProps = {
sectionTitle: string;
projectStatus: string;
commitCount: number;
latestCommitLabel: string | null;
};
export function ProjectPanel({
sectionTitle,
projectStatus,
commitCount,
latestCommitLabel,
}: ProjectPanelProps) {
return (
<Panel title="Project" defaultOpen>
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
<div style={{ marginTop: 6 }}>
Status: <span style={{ color: "#e2e8f0" }}>{projectStatus}</span>
</div>
<div style={{ marginTop: 6 }}>
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
</div>
<div style={{ marginTop: 6 }}>
{latestCommitLabel ? (
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
) : (
<span style={{ color: "#94a3b8" }}>Chưa head commit</span>
)}
</div>
</div>
</Panel>
);
}
@@ -1,25 +1,20 @@
"use client";
import { type CSSProperties, useMemo, useState } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/useEditorState";
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import {
EntityGeometryPreset,
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/uhm/lib/entityTypeOptions";
GeometryPreset,
GeometryTypeGroupId,
GeometryTypeOption,
findGeometryTypeOption,
groupGeometryTypeOptions,
} from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
selectedFeature: Feature | null;
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entityTypeOptions: EntityTypeOption[];
selectedFeatures: Feature[];
entityTypeOptions: GeometryTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
@@ -28,12 +23,7 @@ type Props = {
};
export default function SelectedGeometryPanel({
selectedFeature,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
selectedFeatures,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
@@ -78,15 +68,16 @@ export default function SelectedGeometryPanel({
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
if (!selectedFeature) return null;
if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0];
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findEntityTypeOption(geometryMetaForm.type_key);
const selectedTypeOption = findGeometryTypeOption(geometryMetaForm.type_key);
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
group.options.some((option) => option.value === geometryMetaForm.type_key)
);
@@ -102,7 +93,7 @@ export default function SelectedGeometryPanel({
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>
Entity & Geometry
Geometry property
</div>
<button
type="button"
@@ -129,67 +120,6 @@ export default function SelectedGeometryPanel({
{collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div
style={{
display: "grid",
@@ -305,16 +235,6 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px",
};
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const primaryGeometryButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
@@ -341,20 +261,20 @@ function MinusIcon() {
);
}
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
function resolveFeatureGeometryPreset(feature: Feature): GeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findEntityTypeOption(semanticType);
const option = findGeometryTypeOption(semanticType);
if (option) return option.geometryPreset;
}
return mapGeometryTypeToPreset(feature.geometry.type);
}
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
@@ -369,14 +289,12 @@ function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
return normalizeGeoTypeKey(value);
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
): GeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
@@ -387,8 +305,8 @@ function mapGeometryTypeToPreset(
}
function getAllowedGroupIdsForPreset(
geometryPreset: EntityGeometryPreset
): EntityTypeGroupId[] {
geometryPreset: GeometryPreset
): GeometryTypeGroupId[] {
if (geometryPreset === "point") {
return ["point"];
}
@@ -403,11 +321,3 @@ function getAllowedGroupIdsForPreset(
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}
+66
View File
@@ -0,0 +1,66 @@
type SubmitModalProps = {
isSubmitModalOpen: boolean;
submitContent: string;
setSubmitContent: (content: string) => void;
handleCancelSubmit: () => void;
handleConfirmSubmit: () => void;
};
export function SubmitModal({
isSubmitModalOpen,
submitContent,
setSubmitContent,
handleCancelSubmit,
handleConfirmSubmit,
}: SubmitModalProps) {
if (!isSubmitModalOpen) return null;
const textAreaStyle = {
width: "100%",
marginTop: 8,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
resize: "vertical",
fontFamily: "inherit",
height: 100,
} as const;
return (
<div style={{
position: "fixed",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000
}}>
<div style={{
background: "#0b1220",
padding: 20,
borderRadius: 8,
border: "1px solid #334155",
width: 400,
color: "white"
}}>
<h3 style={{ marginTop: 0 }}>Nội dung Submit</h3>
<textarea
value={submitContent}
onChange={(e) => setSubmitContent(e.target.value)}
placeholder="Nhập nội dung submit..."
style={textAreaStyle}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 15 }}>
<button onClick={handleCancelSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #334155", background: "transparent", color: "white" }}>Hủy</button>
<button onClick={handleConfirmSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "none", background: "#16a34a", color: "white", fontWeight: "bold" }}>Gửi Submit</button>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { Panel } from "./Panel";
import { ModeHint } from "./ModeHint";
type ToolsPanelProps = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
onUndo: () => void;
};
export function ToolsPanel({ mode, setMode, onUndo }: ToolsPanelProps) {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle");
} else {
setMode(newMode);
}
};
const modeButtonStyle = (btnMode: EditorMode) =>
({
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: mode === btnMode ? "#16a34a" : "#111827",
color: "white",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
minHeight: 34,
boxSizing: "border-box",
}) as const;
return (
<Panel title="Tools" defaultOpen>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
Select
</button>
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
Draw
</button>
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
Point
</button>
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
Line
</button>
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
Path
</button>
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
Circle
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
</div>
<ModeHint mode={mode} />
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
style={{
...modeButtonStyle("idle"),
background: "#111827",
}}
onClick={() => setMode("idle")}
title="Tắt tool hiện tại"
>
Idle
</button>
<button
style={{
...modeButtonStyle("idle"),
background: "#334155",
}}
onClick={onUndo}
title="Undo thao tác gần nhất"
>
Undo
</button>
</div>
</Panel>
);
}
@@ -0,0 +1,56 @@
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel";
type UndoListPanelProps = {
undoStack: UndoAction[];
};
export function UndoListPanel({ undoStack }: UndoListPanelProps) {
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
return (
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</Panel>
);
}
export function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
case "snapshot_entities":
case "snapshot_wikis":
case "snapshot_entity_wiki":
case "group":
return action.label;
default:
return "Tác vụ";
}
}
+812
View File
@@ -0,0 +1,812 @@
import maplibregl from "maplibre-gl";
import polylabel from "polylabel";
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import {
FEATURE_STATE_SOURCE_IDS,
PATH_ARROW_ICON_ID,
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
RASTER_BASE_LAYER_ID,
RASTER_BASE_SOURCE_ID,
PATH_ARROW_SOURCE_ID
} from "@/uhm/lib/map/constants";
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
import { newId } from "@/uhm/lib/utils/id";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
type Coordinate = [number, number];
type PolygonCoordinates = Coordinate[][];
type FeatureLabelInfo = {
entityId: string;
label: string;
};
export function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
) {
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (layer.id === RASTER_BASE_LAYER_ID) continue;
if (!map.getLayer(layer.id)) continue;
map.setLayoutProperty(
layer.id,
"visibility",
visibility[layer.id] ? "visible" : "none"
);
}
}
export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
if (shouldShow) {
ensureRasterBaseLayer(map);
return;
}
removeRasterBaseLayer(map);
}
export function ensureRasterBaseLayer(map: maplibregl.Map) {
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
}
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
: undefined;
map.addLayer(createRasterBaseLayer(), beforeId);
}
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
}
export function removeRasterBaseLayer(map: maplibregl.Map) {
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
map.removeLayer(RASTER_BASE_LAYER_ID);
}
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
map.removeSource(RASTER_BASE_SOURCE_ID);
}
}
export function createRasterBaseSource() {
return {
type: "raster" as const,
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
};
}
export function createRasterBaseLayer() {
return {
id: RASTER_BASE_LAYER_ID,
type: "raster" as const,
source: RASTER_BASE_SOURCE_ID,
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear" as const,
},
};
}
export function getSelectableLayers(map: maplibregl.Map): string[] {
const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID];
const style = map.getStyle();
if (!style || !style.layers) return [];
return style.layers
.filter((layer) => "source" in layer && selectableSources.includes(layer.source as string))
.map((layer) => layer.id);
}
export function filterDraftByBinding(
fc: FeatureCollection,
selectedFeatureIds: (string | number)[],
highlightFeatures?: FeatureCollection | null
): FeatureCollection {
const selectedIds = new Set(selectedFeatureIds.map(String));
if (highlightFeatures?.features) {
for (const f of highlightFeatures.features) {
if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
}
}
const childIds = new Set<string>();
for (const feature of fc.features) {
for (const id of normalizeBindingIds(feature.properties.binding)) {
childIds.add(id);
}
}
if (selectedIds.size === 0) {
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
}
const selectedChildren = new Set<string>();
for (const feature of fc.features) {
if (selectedIds.has(String(feature.properties.id))) {
for (const id of normalizeBindingIds(feature.properties.binding)) {
selectedChildren.add(id);
}
}
}
return {
...fc,
features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id);
if (selectedIds.has(featureId)) return true;
if (selectedChildren.has(featureId)) return true;
return !childIds.has(featureId);
}),
};
}
export function filterDraftByGeometryVisibility(
fc: FeatureCollection,
visibility: Record<string, boolean> | null | undefined
): FeatureCollection {
if (!visibility) return fc;
return {
...fc,
features: fc.features.filter((feature) => {
const id = String(feature.properties.id);
// Kiểm tra ẩn theo ID cụ thể (ưu tiên cao nhất)
if (visibility[id] === false) return false;
const key = getFeatureSemanticType(feature);
if (!key) return true;
// Kiểm tra ẩn theo loại (semantic type)
return visibility[key] !== false;
}),
};
}
export function normalizeBindingIds(rawBinding: unknown): string[] {
if (!Array.isArray(rawBinding)) return [];
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of rawBinding) {
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
const id = String(rawId).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
export function splitDraftFeatures(fc: FeatureCollection) {
const polygons = {
type: "FeatureCollection",
features: fc.features.filter((f) =>
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
),
} as FeatureCollection;
const points = {
type: "FeatureCollection",
features: fc.features.filter((f) =>
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
),
} as FeatureCollection;
return { polygons, points };
}
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext);
return {
...fc,
features: fc.features.map((feature) => ({
...feature,
properties: {
...feature.properties,
point_label: getLabel(feature),
},
})),
};
}
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext);
return {
...fc,
features: fc.features.map((feature) => ({
...feature,
properties: {
...feature.properties,
line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null,
},
})),
};
}
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext);
const features: Feature[] = [];
for (const feature of fc.features) {
const label = getLabel(feature);
if (!label) continue;
const labelPoint = getPolygonLabelPoint(feature.geometry);
if (!labelPoint) continue;
features.push({
type: "Feature",
properties: {
...feature.properties,
id: `${feature.properties.id}:polygon-label`,
polygon_label: label,
},
geometry: {
type: "Point",
coordinates: labelPoint,
},
});
}
return { type: "FeatureCollection", features };
}
export function setSelectedFeatureState(
map: maplibregl.Map,
id: string | number | null,
selected: boolean
) {
if (id === null) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (!map.getSource(sourceId)) continue;
map.setFeatureState({ source: sourceId, id }, { selected });
}
}
export function fitMapToFeatureCollection(
map: maplibregl.Map,
fc: FeatureCollection,
padding?: number | maplibregl.PaddingOptions,
options?: {
duration?: number;
maxZoom?: number;
pointZoom?: number;
}
): boolean {
const bbox = getFeatureCollectionBBox(fc);
if (!bbox) return false;
const resolvedPadding = typeof padding === "number" || padding ? padding : 58;
const duration = options?.duration ?? 0;
const maxZoom = options?.maxZoom ?? 7;
const pointZoom = options?.pointZoom ?? 6;
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
if (lngSpan < 0.000001 && latSpan < 0.000001) {
map.easeTo({
center: [bbox.minLng, bbox.minLat],
zoom: pointZoom,
padding: resolvedPadding,
duration,
});
return true;
}
map.fitBounds(
[
[bbox.minLng, bbox.minLat],
[bbox.maxLng, bbox.maxLat],
],
{
padding: resolvedPadding,
maxZoom,
duration,
}
);
return true;
}
export function getFeatureCollectionBBox(
fc: FeatureCollection
): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
if (!points.length) return null;
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const [lng, lat] of points) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { minLng, minLat, maxLng, maxLat };
}
export function collectCoordinatePairs(value: unknown): Array<[number, number]> {
if (!Array.isArray(value)) return [];
if (
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number" &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
) {
return [[value[0], value[1]]];
}
return value.flatMap((item) => collectCoordinatePairs(item));
}
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features: Feature[] = [];
for (const feature of fc.features) {
if (!isPathFeature(feature)) continue;
const coordinateGroups = getLineCoordinateGroups(feature.geometry);
for (const coordinates of coordinateGroups) {
const geometry = buildPathArrowGeometry(coordinates);
if (!geometry) continue;
features.push({
type: "Feature",
properties: { ...feature.properties },
geometry,
});
}
}
return {
type: "FeatureCollection",
features,
};
}
export function isPathFeature(feature: Feature): boolean {
const featureType = getFeatureSemanticType(feature);
return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
}
export function getFeatureSemanticType(feature: Feature): string | null {
const value = feature.properties.type || feature.properties.entity_type_id || null;
return normalizeGeoTypeKey(value);
}
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
const sourceCoords = removeDuplicatePathCoords(coords);
if (sourceCoords.length < 2) return null;
const origin = sourceCoords[0];
const originLatRad = toRadians(origin[1]);
const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
const measured = buildMeasuredPath(projected);
const totalLength = measured[measured.length - 1]?.distance || 0;
if (totalLength <= 0) return null;
const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
const bodyPoints = measured
.filter((point) => point.distance < bodyEndDistance)
.map(({ x, y, distance }) => ({ x, y, distance }));
bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
if (bodyPoints.length < 2) return null;
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
const headWidth = shoulderWidth * 2.0;
const leftBody: ProjectedPoint[] = [];
const rightBody: ProjectedPoint[] = [];
for (let i = 0; i < bodyPoints.length; i += 1) {
const point = bodyPoints[i];
const normal = normalAt(bodyPoints, i);
const progress = bodyEndDistance > 0
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
: 0;
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
const half = width / 2;
leftBody.push({
x: point.x + normal.x * half,
y: point.y + normal.y * half,
});
rightBody.push({
x: point.x - normal.x * half,
y: point.y - normal.y * half,
});
}
const base = bodyPoints[bodyPoints.length - 1];
const tip = pointAtDistance(measured, totalLength);
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
const headHalf = headWidth / 2;
const headBaseLeft = {
x: base.x + headNormal.x * headHalf,
y: base.y + headNormal.y * headHalf,
};
const headBaseRight = {
x: base.x - headNormal.x * headHalf,
y: base.y - headNormal.y * headHalf,
};
const ring = [
...leftBody,
headBaseLeft,
{ x: tip.x, y: tip.y },
headBaseRight,
...rightBody.reverse(),
leftBody[0],
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
if (ring.length < 4) return null;
return {
type: "Polygon",
coordinates: [ring],
};
}
export type ProjectedPoint = {
x: number;
y: number;
};
export type MeasuredPoint = ProjectedPoint & {
distance: number;
};
export function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
const result: [number, number][] = [];
for (const coord of coords) {
const last = result[result.length - 1];
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
result.push(coord);
}
return result;
}
export function projectLngLat(
coord: [number, number],
origin: [number, number],
cosOriginLat: number
): ProjectedPoint {
const earthRadiusMeters = 6371008.8;
return {
x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
};
}
export function unprojectLngLat(
point: ProjectedPoint,
origin: [number, number],
cosOriginLat: number
): [number, number] {
const earthRadiusMeters = 6371008.8;
return [
origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
origin[1] + toDegrees(point.y / earthRadiusMeters),
];
}
export function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
let distance = 0;
return points.map((point, index) => {
if (index > 0) {
distance += distanceProjected(points[index - 1], point);
}
return {
...point,
distance,
};
});
}
export function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
if (targetDistance <= 0) return points[0];
for (let i = 1; i < points.length; i += 1) {
const prev = points[i - 1];
const next = points[i];
if (targetDistance > next.distance) continue;
const segmentLength = next.distance - prev.distance;
const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
return {
x: prev.x + (next.x - prev.x) * t,
y: prev.y + (next.y - prev.y) * t,
distance: targetDistance,
};
}
return points[points.length - 1];
}
export function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
const prev = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
return normalFromSegment(prev, next) || { x: 0, y: 1 };
}
export function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (length <= 0) return null;
return {
x: -dy / length,
y: dx / length,
};
}
export function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
export function toRadians(value: number): number {
return (value * Math.PI) / 180;
}
export function toDegrees(value: number): number {
return (value * 180) / Math.PI;
}
export function ensurePathArrowIcon(map: maplibregl.Map): boolean {
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
const imageData = createPathArrowImageData();
if (!imageData) return false;
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
return true;
}
export function createPathArrowImageData(): ImageData | null {
const size = 56;
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.clearRect(0, 0, size, size);
ctx.strokeStyle = "#0f172a";
ctx.fillStyle = "#38bdf8";
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(8, 16);
ctx.lineTo(28, 16);
ctx.lineTo(28, 10);
ctx.lineTo(46, 28);
ctx.lineTo(28, 46);
ctx.lineTo(28, 40);
ctx.lineTo(8, 40);
ctx.closePath();
ctx.fill();
ctx.stroke();
return ctx.getImageData(0, 0, size, size);
}
export function buildTypeMatchExpression(
valueByType: Record<string, string | number | boolean>,
fallback: string | number | boolean
): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, value] of Object.entries(valueByType)) {
expression.push(typeId, value);
}
expression.push(fallback);
return expression as maplibregl.ExpressionSpecification;
}
export function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
return [
"coalesce",
["get", "type"],
["get", "entity_type_id"],
"",
] as maplibregl.ExpressionSpecification;
}
export function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null {
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
for (const feature of fc.features) {
const labelInfo = getSingleEntityFeatureLabelInfo(feature);
if (!labelInfo) continue;
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
}
for (const feature of fc.features) {
const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id));
const featureId = String(feature.properties.id);
const bindingIds = normalizeBindingIds(feature.properties.binding);
if (parentLabel) {
for (const childId of bindingIds) {
mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel);
}
}
for (const parentId of bindingIds) {
const linkedParentLabel = directLabelsByFeatureId.get(parentId);
if (linkedParentLabel) {
mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel);
}
}
}
return (feature) => {
const featureId = String(feature.properties.id);
const directEntityIds = getFeatureEntityIds(feature);
if (directEntityIds.length > 0) {
return directLabelsByFeatureId.get(featureId)?.label || null;
}
return inheritedLabelsByChildId.get(featureId)?.label || null;
};
}
function mergeInheritedFeatureLabel(
labelsByFeatureId: Map<string, FeatureLabelInfo | null>,
targetFeatureId: string,
labelInfo: FeatureLabelInfo
) {
const current = labelsByFeatureId.get(targetFeatureId);
if (current === undefined) {
labelsByFeatureId.set(targetFeatureId, labelInfo);
} else if (current && current.entityId === labelInfo.entityId) {
labelsByFeatureId.set(targetFeatureId, current);
} else {
labelsByFeatureId.set(targetFeatureId, null);
}
}
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null {
const entityIds = getFeatureEntityIds(feature);
if (entityIds.length !== 1) return null;
const label = getSingleEntityName(feature);
if (!label) return null;
return { entityId: entityIds[0], label };
}
function getFeatureEntityIds(feature: Feature): string[] {
const rawEntityIds: unknown[] = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids
: (typeof feature.properties.entity_id === "string" || typeof feature.properties.entity_id === "number"
? [feature.properties.entity_id]
: []);
return Array.from(new Set(
rawEntityIds
.filter((id): id is string | number => typeof id === "string" || typeof id === "number")
.map((id) => String(id).trim())
.filter((id) => id.length > 0)
));
}
function getSingleEntityName(feature: Feature): string | null {
const directName = typeof feature.properties.entity_name === "string"
? feature.properties.entity_name.trim()
: "";
if (directName.length > 0) return directName;
const names = Array.isArray(feature.properties.entity_names)
? Array.from(new Set(
feature.properties.entity_names
.filter((name): name is string => typeof name === "string")
.map((name) => name.trim())
.filter((name) => name.length > 0)
))
: [];
return names.length === 1 ? names[0] : null;
}
function isLineGeometry(geometry: Geometry): boolean {
return geometry.type === "LineString" || geometry.type === "MultiLineString";
}
function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
if (geometry.type === "LineString") return [geometry.coordinates];
if (geometry.type === "MultiLineString") return geometry.coordinates;
return [];
}
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
if (geometry.type === "Polygon") {
return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
}
if (geometry.type === "MultiPolygon") {
let best: { point: Coordinate; distance: number } | null = null;
for (const polygon of geometry.coordinates) {
const candidate = getPolygonLabelCandidate(polygon);
if (!candidate) continue;
if (!best || candidate.distance > best.distance) {
best = candidate;
}
}
return best?.point || null;
}
return null;
}
function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null {
const outerRing = polygon[0];
if (!outerRing || outerRing.length < 3) return null;
const bbox = getRingBbox(outerRing);
if (!bbox) return null;
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
if (width <= 0 || height <= 0) {
const fallback: Coordinate = [bbox.minX, bbox.minY];
return { point: fallback, distance: 0 };
}
const precision = Math.max(Math.max(width, height) / 100, 0.0001);
const result = polylabel(polygon, precision);
const x = result[0];
const y = result[1];
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return { point: [bbox.minX + width / 2, bbox.minY + height / 2], distance: 0 };
}
return { point: [x, y], distance: Number.isFinite(result.distance) ? result.distance : 0 };
}
function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: number; maxY: number } | null {
if (!ring.length) return null;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const [x, y] of ring) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return null;
}
return { minX, minY, maxX, maxY };
}
export function buildClientFeatureId(): string {
return newId();
}
export function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState, useCallback } from "react";
import maplibregl from "maplibre-gl";
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
import { clampNumber, roundZoom } from "./mapUtils";
import { getBaseMapStyle } from "./useMapLayers";
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
}
export function useMapInstance() {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
} catch {
return false;
}
});
const [isMapLoaded, setIsMapLoaded] = useState(false);
const geolocationCenteredRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
MAP_PROJECTION_STORAGE_KEY,
isGlobeProjection ? "globe" : "mercator"
);
} catch {
// ignore
}
}, [isGlobeProjection]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
try {
const map = new maplibregl.Map({
container,
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
style: getBaseMapStyle(),
center: [0, 20],
zoom: 2,
});
mapRef.current = map;
const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom()));
};
map.on("load", () => {
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
syncZoomLevel();
map.on("zoom", syncZoomLevel);
setIsMapLoaded(true);
});
return () => {
map.off("zoom", syncZoomLevel);
setIsMapLoaded(false);
if (mapRef.current === map) {
mapRef.current = null;
}
map.remove();
};
} catch (err) {
console.error("Map initialization failed", err);
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
}
}, []);
// Sync Map Projection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (mapRef.current !== map) return;
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
applyMapProjection(map, isGlobeProjection);
};
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
apply();
return;
}
map.once("load", apply);
map.once("style.load", apply);
return () => {
map.off("load", apply);
map.off("style.load", apply);
};
}, [isGlobeProjection]);
const handleZoomByStep = useCallback((delta: number) => {
const map = mapRef.current;
if (!map) return;
setZoomLevel((prev) => {
const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 120 });
return next;
});
}, [zoomBounds]);
const handleZoomSliderChange = useCallback((nextRaw: number) => {
const map = mapRef.current;
if (!map || !Number.isFinite(nextRaw)) return;
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 80 });
setZoomLevel(next);
}, [zoomBounds]);
return {
mapRef,
containerRef,
fatalInitError,
setFatalInitError,
zoomLevel,
zoomBounds,
isGlobeProjection,
setIsGlobeProjection,
isMapLoaded,
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
};
}
+321
View File
@@ -0,0 +1,321 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine";
import { initSelect } from "@/uhm/lib/map/engines/selectingEngine";
import { initPoint } from "@/uhm/lib/map/engines/pointEngine";
import { initLine } from "@/uhm/lib/map/engines/lineEngine";
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
import { MapHoverPayload } from "../Map";
type EngineBinding = {
cleanup: () => void;
cancel?: () => void;
clearSelection?: () => void;
};
type UseMapInteractionProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
mode: EditorMode;
modeRef: React.MutableRefObject<EditorMode>;
draftRef: React.MutableRefObject<FeatureCollection>;
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
};
export function useMapInteraction({
mapRef,
mode,
modeRef,
draftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onUpdateRef,
onHoverFeatureChangeRef,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
const previousModeRef = useRef<EditorMode>(mode);
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
useEffect(() => {
if (!editingEngineRef.current) {
editingEngineRef.current = createEditingEngine({
mapRef,
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
});
}
}, [mapRef, onUpdateRef]);
useEffect(() => {
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing();
}
}, [mode, selectedFeatureIds]);
useEffect(() => {
const previousMode = previousModeRef.current;
if (previousMode !== mode) {
engineBindingsRef.current[previousMode]?.cancel?.();
previousModeRef.current = mode;
}
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "draw") {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-path") {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-circle") {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
}, [mode, mapRef]);
const setupMapInteractions = (map: maplibregl.Map) => {
const drawingEngine = initDrawing(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "country",
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
});
}
);
const selectEngine = initSelect(
map,
() => modeRef.current,
allowGeometryEditing
? (id: string | number) => {
editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id);
}
: undefined,
allowGeometryEditing
? (feature) => {
const rawId = feature.id ?? feature.properties?.id;
const originalFeature = draftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
}
: undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id)
);
const cleanupPoint = initPoint(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "city",
geometry_preset: "point",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
});
}
);
const lineEngine = initLine(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "defense_line",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
});
}
);
const pathEngine = initPath(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "attack_route",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
});
}
);
const circleEngine = initCircle(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "war",
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
});
}
);
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
replay: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
};
mapCleanupFnsRef.current.push(
circleEngine.cleanup,
pathEngine.cleanup,
lineEngine.cleanup,
cleanupPoint,
selectEngine.cleanup,
drawingEngine.cleanup
);
const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
const callback = onHoverFeatureChangeRef.current;
if (!callback) return;
const selectableLayers = getSelectableLayers(map);
if (!selectableLayers.length) {
callback(null);
return;
}
const features = map.queryRenderedFeatures(event.point, {
layers: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
const feature = features[0];
const rawFeatureId = feature?.id ?? feature?.properties?.id;
if (rawFeatureId === undefined || rawFeatureId === null) {
callback(null);
return;
}
const currentFeature =
draftRef.current.features.find(
(item) => String(item.properties.id) === String(rawFeatureId)
) || null;
callback({
featureId: rawFeatureId,
feature: currentFeature,
point: { x: event.point.x, y: event.point.y },
lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
});
};
const handleCanvasMouseLeave = () => {
onHoverFeatureChangeRef.current?.(null);
};
map.on("mousemove", handleHoverMove);
mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
mapCleanupFnsRef.current.push(() => {
map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
});
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
};
const cleanupMapInteractions = () => {
for (const cleanupFn of mapCleanupFnsRef.current) {
cleanupFn();
}
mapCleanupFnsRef.current = [];
engineBindingsRef.current = {};
};
return {
editingEngineRef,
setupMapInteractions,
cleanupMapInteractions,
};
}
+434
View File
@@ -0,0 +1,434 @@
import { useEffect } from "react";
import maplibregl from "maplibre-gl";
import { getVectorTileTemplateUrl } from "@/uhm/api/tiles";
import {
COUNTRY_FILL_COLOR_EXPRESSION,
LINE_COLOR_BY_TYPE,
PATH_RENDER_BY_TYPE,
POLYGON_FILL_BY_TYPE,
POLYGON_OPACITY_BY_TYPE,
POLYGON_STROKE_BY_TYPE,
} from "@/uhm/lib/map/styles/style";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import {
applyBackgroundLayerVisibility,
buildTypeMatchExpression,
ensurePathArrowIcon,
} from "./mapUtils";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
export function getBaseMapStyle(): maplibregl.StyleSpecification {
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
base: {
type: "vector",
tiles: [getVectorTileTemplateUrl()],
minzoom: 0,
maxzoom: 6,
},
},
layers: [
{
id: "background",
type: "background",
paint: {
"background-color": "#0b1220",
},
},
{
id: "graticules-line",
type: "line",
source: "base",
"source-layer": "ne_10m_graticules_10",
paint: {
"line-color": "#334155",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.3,
4, 0.6,
6, 0.8,
],
"line-opacity": 0.55,
},
},
{
id: "land",
type: "fill",
source: "base",
"source-layer": "ne_10m_land",
paint: {
"fill-color": "#1e293b",
"fill-opacity": 0.25,
},
},
{
id: "bg-countries-fill",
type: "fill",
source: "base",
"source-layer": "ne_10m_admin_0_countries",
paint: {
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
"fill-opacity": 0.38,
},
},
{
id: "bg-country-borders-line",
type: "line",
source: "base",
"source-layer": "ne_10m_admin_0_boundary_lines_land",
paint: {
"line-color": "#cbd5e1",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.2,
4, 0.5,
6, 1.1,
],
"line-opacity": 0.85,
},
},
{
id: "country-labels",
type: "symbol",
source: "base",
"source-layer": "country_labels",
minzoom: 0,
layout: {
"text-field": [
"coalesce",
["get", "NAME_EN"],
["get", "NAME"],
["get", "ADMIN"],
["get", "name"],
"",
],
"text-size": [
"interpolate",
["linear"],
["zoom"],
0, 15,
1, 16,
2, 17,
4, 19,
6, 23,
],
"text-padding": 0,
"text-max-width": 10,
"text-allow-overlap": true,
"text-ignore-placement": true,
"symbol-placement": "point",
},
paint: {
"text-color": "#e2e8f0",
"text-halo-color": "#0b1220",
"text-halo-width": 1.2,
"text-halo-blur": 0.5,
},
},
{
id: "regions-line",
type: "line",
source: "base",
"source-layer": "ne_10m_geography_regions_polys",
paint: {
"line-color": "#475569",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.2,
4, 0.6,
6, 1,
],
"line-opacity": 0.6,
},
},
{
id: "lakes-fill",
type: "fill",
source: "base",
"source-layer": "ne_10m_lakes",
paint: {
"fill-color": "#1d4ed8",
"fill-opacity": 0.45,
},
},
{
id: "rivers-line",
type: "line",
source: "base",
"source-layer": "ne_10m_rivers_lake_centerlines",
paint: {
"line-color": "#38bdf8",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.25,
4, 0.8,
6, 1.5,
],
"line-opacity": 0.85,
},
},
{
id: "geolines-line",
type: "line",
source: "base",
"source-layer": "ne_10m_geographic_lines",
paint: {
"line-color": "#94a3b8",
"line-width": 1.2,
"line-opacity": 0.8,
},
},
],
};
}
export function setupMapLayers(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility,
highlightFeatures: FeatureCollection | null,
applyHighlightToMap: (fc: FeatureCollection) => void
) {
applyBackgroundLayerVisibility(map, backgroundVisibility);
const hasPathArrowIcon = ensurePathArrowIcon(map);
// preview (drawing)
map.addSource("draw-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-preview-fill",
type: "fill",
source: "draw-preview",
paint: {
"fill-color": "#22c55e",
"fill-opacity": 0.4,
},
});
map.addLayer({
id: "draw-preview-line",
type: "line",
source: "draw-preview",
paint: {
"line-color": "#16a34a",
"line-width": 2,
},
});
map.addSource("draw-circle-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-circle-preview-fill",
type: "fill",
source: "draw-circle-preview",
paint: {
"fill-color": "#0ea5e9",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "draw-circle-preview-line",
type: "line",
source: "draw-circle-preview",
paint: {
"line-color": "#0284c7",
"line-width": 2,
"line-opacity": 0.95,
},
});
map.addSource("draw-line-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-line-preview-line",
type: "line",
source: "draw-line-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
map.addSource("draw-path-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-path-preview-line",
type: "line",
source: "draw-path-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
if (hasPathArrowIcon) {
map.addLayer({
id: "draw-path-preview-arrows",
type: "symbol",
source: "draw-path-preview",
layout: {
"symbol-placement": "line",
"symbol-spacing": 56,
"icon-image": PATH_ARROW_ICON_ID,
"icon-size": 0.45,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
}
// data
map.addSource("countries", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(PATH_ARROW_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
map.addSource("places", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(POLYGON_LABEL_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
ensurePointGeotypeIcons(map);
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
for (const layer of geotypeLayers) {
map.addLayer(layer);
}
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries");
for (const layer of geotypeLabelLayers) {
map.addLayer(layer);
}
// editing overlays
map.addSource("edit-shape", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addSource("edit-handles", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "edit-shape-line",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
},
});
map.addLayer({
id: "edit-handles-circle",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": "#f97316",
"circle-radius": 12,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 3,
},
});
map.addSource("entity-focus", {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
});
map.addLayer({
id: "entity-focus-fill",
type: "fill",
source: "entity-focus",
filter: [
"any",
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
],
paint: {
"fill-color": "#fde047",
"fill-opacity": 0.2,
},
});
map.addLayer({
id: "entity-focus-line",
type: "line",
source: "entity-focus",
paint: {
"line-color": "#f59e0b",
"line-width": [
"interpolate",
["linear"],
["zoom"],
1, 2.4,
4, 4,
6, 5.5,
],
"line-opacity": 0.98,
},
});
map.addLayer({
id: "entity-focus-points",
type: "circle",
source: "entity-focus",
filter: [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
],
paint: {
"circle-color": "#f8fafc",
"circle-radius": 8,
"circle-stroke-color": "#f59e0b",
"circle-stroke-width": 3,
"circle-opacity": 1,
},
});
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import {
applyBackgroundLayerVisibility,
buildPolygonLabelFeatureCollection,
buildPathArrowFeatureCollection,
decorateLineFeaturesWithLabels,
decoratePointFeaturesWithLabels,
filterDraftByBinding,
filterDraftByGeometryVisibility,
fitMapToFeatureCollection,
setSelectedFeatureState,
splitDraftFeatures,
} from "./mapUtils";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
draft: FeatureCollection;
labelContextDraft?: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
respectBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>;
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
};
export function useMapSync({
mapRef,
draft,
labelContextDraft,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
focusFeatureCollection,
focusRequestKey,
focusPadding,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
}: UseMapSyncProps) {
const draftRef = useRef<FeatureCollection>(draft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const respectBindingFilterRef = useRef(respectBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const fitBoundsAppliedRef = useRef(false);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource || !polygonLabelSource) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (map.getSource(sourceId)) {
map.removeFeatureState({ source: sourceId });
}
}
const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
: fc;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const labelContext = labelContextDraftRef.current || fc;
const { polygons, points } = splitDraftFeatures(visibleDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
countriesSource.setData(labeledGeometries);
placesSource.setData(labeledPoints);
polygonLabelSource.setData(polygonLabels);
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
const currentSelectedIds = selectedFeatureIdsRef.current;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
requestAnimationFrame(() => {
if (mapRef.current !== map) return;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
}
}, [mapRef]);
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
if (!source) return;
source.setData(fc);
}, [mapRef]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
if (!map) return;
geolocationCenteredRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (mapRef.current !== map) return;
const { longitude, latitude } = pos.coords;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
},
() => { },
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
);
}, [mapRef, geolocationCenteredRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}, [highlightFeatures, mapRef]);
useEffect(() => {
applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
draft,
labelContextDraft,
selectedFeatureIds,
respectBindingFilter,
geometryVisibility,
highlightFeatures,
applyDraftToMap,
editingEngineRef,
]);
useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current;
const target = focusFeatureCollection;
if (!target || !target.features.length) return;
if (!map) return;
let cancelled = false;
let rafId: number | null = null;
const focus = () => {
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
fitMapToFeatureCollection(map, target, focusPadding, {
duration: 550,
maxZoom: 10,
pointZoom: 9,
});
};
if (map.isStyleLoaded()) {
rafId = requestAnimationFrame(focus);
} else {
map.once("idle", focus);
}
return () => {
cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
return {
applyDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation,
};
}
@@ -1,10 +1,12 @@
"use client";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
type Props = {
year: number;
onYearChange: (year: number) => void;
timeRange?: number;
onTimeRangeChange?: (range: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
@@ -15,6 +17,8 @@ type Props = {
export default function TimelineBar({
year,
onYearChange,
timeRange,
onTimeRangeChange,
isLoading,
disabled,
statusText,
@@ -34,6 +38,12 @@ export default function TimelineBar({
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
};
const handleTimeRangeChange = (nextValue: number) => {
if (!onTimeRangeChange) return;
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
onTimeRangeChange(Math.max(0, Math.min(30, safe)));
};
return (
<div
style={{
@@ -148,6 +158,41 @@ export default function TimelineBar({
outline: "none",
}}
/>
{typeof timeRange === "number" && onTimeRangeChange ? (
<label
title="time_range (0-30)"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#94a3b8",
whiteSpace: "nowrap",
opacity: effectiveDisabled ? 0.6 : 1,
}}
>
<span style={{ fontSize: "12px" }}>Range</span>
<input
type="number"
min={0}
max={30}
step={1}
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline range"
style={{
width: "84px",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
background: "rgba(15, 23, 42, 0.7)",
color: "#f8fafc",
fontSize: "13px",
outline: "none",
}}
/>
</label>
) : null}
</div>
</div>
);
@@ -0,0 +1,387 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
level: number;
text: string;
};
type Props = {
entity: Entity | null;
wiki: Wiki | null;
isLoading: boolean;
error?: string | null;
onClose: () => void;
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function tiptapJsonToPlainText(node: unknown): string {
if (node == null) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
if (isRecord(node)) {
if (node.type === "text" && typeof node.text === "string") return node.text;
if (node.type === "hardBreak") return "\n";
if ("content" in node) return tiptapJsonToPlainText(node.content);
}
return "";
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
const value = String(raw || "").trim();
if (!value.length) return "";
if (value[0] === "<") return value;
if (value[0] === "{") {
try {
const json: unknown = JSON.parse(value);
const text = tiptapJsonToPlainText(json).trim();
if (!text.length) return "";
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
} catch {
// fall through
}
}
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isExternalHref(href: string): boolean {
const h = href.trim().toLowerCase();
return (
h.startsWith("http://") ||
h.startsWith("https://") ||
h.startsWith("mailto:") ||
h.startsWith("tel:") ||
h.startsWith("sms:")
);
}
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
if (href.startsWith("#")) continue;
if (href.startsWith("/")) continue;
if (isExternalHref(href)) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
continue;
}
const match = href.match(/^([^?#]+)([?#].*)?$/);
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
if (!slugPart.length) continue;
a.setAttribute("href", `#wiki:${slugPart}`);
a.setAttribute("data-wiki-slug", slugPart);
a.setAttribute("target", "_self");
}
const toc: TocItem[] = [];
const seen = new Map<string, number>();
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
for (const h of headings) {
const text = String(h.textContent || "").trim();
if (!text.length) continue;
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
const existingId = String(h.getAttribute("id") || "").trim();
if (existingId) {
toc.push({ id: existingId, level, text });
continue;
}
const base = slugifyHeading(text) || "heading";
const nextCount = (seen.get(base) || 0) + 1;
seen.set(base, nextCount);
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
h.setAttribute("id", id);
toc.push({ id, level, text });
}
return { html: doc.body.innerHTML, toc };
}
export default function PublicWikiSidebar({
entity,
wiki,
isLoading,
error,
onClose,
onWikiLinkRequest,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const processedWiki = useMemo(() => {
if (!wiki) return { html: "", toc: [] as TocItem[] };
const html = normalizeWikiContentToHtml(wiki.content ?? "");
try {
return prepareWikiHtml(html);
} catch (err) {
console.error("Failed to process sidebar wiki HTML", err);
return { html, toc: [] as TocItem[] };
}
}, [wiki]);
const renderHtml = processedWiki.html;
const toc = processedWiki.toc;
const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId)
? activeHeadingId
: (toc[0]?.id ?? null);
useEffect(() => {
if (!toc.length) return;
const root = contentRootRef.current;
if (!root) return;
const headings = toc
.map((item) => root.querySelector<HTMLElement>(`#${CSS.escape(item.id)}`))
.filter((item): item is HTMLElement => Boolean(item));
if (!headings.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
const top = visible[0]?.target as HTMLElement | undefined;
if (top?.id) setActiveHeadingId(top.id);
},
{ root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
);
for (const heading of headings) observer.observe(heading);
return () => observer.disconnect();
}, [toc]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
if (!link) return;
event.preventDefault();
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
if (!slug.length) return;
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
};
root.addEventListener("click", handleClick);
return () => root.removeEventListener("click", handleClick);
}, [onWikiLinkRequest, renderHtml]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400">
Wiki
</div>
<div className="mt-1 text-lg font-semibold leading-tight text-gray-900 dark:text-gray-100">
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
</div>
{entity?.description?.trim() ? (
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
{entity.description.trim()}
</div>
) : null}
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{wiki.title.trim()}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-sm text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.04] dark:hover:text-gray-100"
aria-label="Close wiki sidebar"
>
x
</button>
</div>
</div>
{toc.length ? (
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="flex gap-2 overflow-x-auto pb-1">
{toc.slice(0, 8).map((item) => {
const isActive = effectiveActiveHeadingId === item.id;
return (
<a
key={item.id}
href={`#${item.id}`}
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
}`}
>
{item.text}
</a>
);
})}
</div>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<div className="space-y-3 px-4 py-4">
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
</div>
) : error ? (
<div className="px-4 py-4 text-sm text-red-600 dark:text-red-300">
{error}
</div>
) : wiki ? (
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor text-sm text-gray-900 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
) : (
<div className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
Entity này chưa wiki liên kết.
</div>
)}
</div>
<style jsx global>{`
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 18px 22px;
}
.uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
margin: 1.15em 0 0.6em;
font-size: 1.6em;
font-weight: 800;
line-height: 1.2;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
margin: 1.05em 0 0.55em;
font-size: 1.3em;
font-weight: 800;
line-height: 1.25;
}
.uhm-wiki-sidebar-view.ql-editor h3,
.uhm-wiki-sidebar-view.ql-editor h4,
.uhm-wiki-sidebar-view.ql-editor h5,
.uhm-wiki-sidebar-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
font-size: 1.05em;
font-weight: 700;
line-height: 1.3;
}
.uhm-wiki-sidebar-view.ql-editor ul,
.uhm-wiki-sidebar-view.ql-editor ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-sidebar-view.ql-editor 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);
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
border-left-color: rgba(100, 116, 139, 0.6);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-sidebar-view.ql-editor pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1);
border-radius: 10px;
background: rgba(248, 250, 252, 1);
overflow: auto;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4);
}
.uhm-wiki-sidebar-view.ql-editor img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #2563eb;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #60a5fa;
}
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
.uhm-wiki-sidebar-view.ql-editor a[href=""],
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #dc2626;
}
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a:not([href]),
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href=""],
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #f87171;
}
`}</style>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/backgroundLayers";
} from "@/uhm/lib/map/styles/backgroundLayers";
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
+3 -2
View File
@@ -6,7 +6,7 @@ import type {
} from "@/uhm/types/geo";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
export type Change = GeometryChange;
@@ -18,4 +18,5 @@ export type UndoAction =
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] };
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] }
| { type: "group"; label: string; actions: UndoAction[] };
+21 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
@@ -10,31 +10,32 @@ export function useUndoStack(options: Options) {
const { applyUndoAction } = options;
// Stack thao tác undo (append-only, pop khi undo).
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const undoStackRef = useRef<UndoAction[]>([]);
const pushUndo = useCallback((action: UndoAction) => {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev;
return [...prev, action];
});
const prev = undoStackRef.current;
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return;
const next = [...prev, action];
undoStackRef.current = next;
setUndoStack(next);
}, []);
const undo = useCallback(() => {
let applied = false;
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
const current = undoStackRef.current;
if (!current.length) return;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
applied = true;
const last = current[current.length - 1];
const didApply = applyUndoAction(last);
if (!didApply) return;
const didApply = applyUndoAction(last);
return didApply ? remaining : prev;
});
const remaining = current.slice(0, -1);
undoStackRef.current = remaining;
setUndoStack(remaining);
}, [applyUndoAction]);
const clearUndo = useCallback(() => {
undoStackRef.current = [];
setUndoStack([]);
}, []);
@@ -87,6 +88,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
}
case "group": {
const next = b as Extract<UndoAction, { type: "group" }>;
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
}
default:
return false;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Entity } from "@/uhm/types/entities";
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { newId } from "@/uhm/lib/id";
import { newId } from "@/uhm/lib/utils/id";
export function mergeEntitySearchResults(
remoteRows: Entity[],
@@ -2,17 +2,17 @@ import { useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import { ApiError } from "@/uhm/api/http";
import {
createSection,
createSectionCommit,
fetchSectionCommits,
fetchSections,
createProject,
createProjectCommit,
fetchProjectCommits,
fetchProjects,
openSectionEditor,
submitSection,
} from "@/uhm/api/sections";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
} from "@/uhm/api/projects";
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
@@ -27,9 +27,9 @@ type Options = {
editor: EditorDraftApi;
editorUserId: string;
emptyFeatureCollection: FeatureCollection;
activeSection: Section | null;
sectionState: SectionState | null;
selectedSectionId: string;
activeSection: Project | null;
projectState: ProjectState | null;
selectedProjectId: string;
newSectionTitle: string;
pendingSaveCount: number;
snapshotEntities: EntitySnapshot[];
@@ -37,54 +37,52 @@ type Options = {
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
baselineSnapshot: EditorSnapshot | null;
commitTitle: string;
commitNote: string;
setActiveSection: Dispatch<SetStateAction<Section | null>>;
setSelectedSectionId: Dispatch<SetStateAction<string>>;
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
setActiveSection: Dispatch<SetStateAction<Project | null>>;
setSelectedProjectId: Dispatch<SetStateAction<string>>;
setProjectState: Dispatch<SetStateAction<ProjectState | null>>;
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
setProjectCommits: Dispatch<SetStateAction<ProjectCommit[]>>;
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
setIsSaving: Dispatch<SetStateAction<boolean>>;
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
setAvailableSections: Dispatch<SetStateAction<Project[]>>;
setNewSectionTitle: Dispatch<SetStateAction<string>>;
setCommitTitle: Dispatch<SetStateAction<string>>;
setCommitNote: Dispatch<SetStateAction<string>>;
};
export function useSectionCommands(options: Options) {
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId);
export function useProjectCommands(options: Options) {
const openSectionForEditing = useCallback(async (projectId: string) => {
const editorPayload = await openSectionEditor(projectId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
// operations should not carry over as deltas into the next commit.
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const commits = await fetchSectionCommits(sectionId);
const commits = await fetchProjectCommits(projectId);
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
options.setActiveSection(editorPayload.section);
options.setSelectedSectionId(editorPayload.section.id);
options.setSectionState(editorPayload.state);
options.setActiveSection(editorPayload.project);
options.setSelectedProjectId(editorPayload.project.id);
options.setProjectState(editorPayload.state);
options.setBaselineSnapshot(sessionSnapshot);
options.setInitialData(nextInitialData);
options.setSectionCommits(commits);
options.setProjectCommits(commits);
options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null);
options.setSelectedFeatureIds([]);
options.setEntityFormStatus(null);
}, [options]);
const commitSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
if (!options.activeSection || !options.projectState) {
options.setEntityStatus("Chưa mở được project editor.");
return;
}
if (options.pendingSaveCount <= 0) {
@@ -97,7 +95,7 @@ export function useSectionCommands(options: Options) {
options.setEntityStatus(null);
try {
const snapshot = buildEditorSnapshot({
section: options.activeSection,
project: options.activeSection,
draft: options.editor.draft,
changes: geometryChanges,
snapshotEntities: options.snapshotEntities,
@@ -107,13 +105,12 @@ export function useSectionCommands(options: Options) {
hasPersistedFeature: options.editor.hasPersistedFeature,
});
const editSummary = options.commitTitle.trim()
|| options.commitNote.trim()
|| `Edit ${new Date().toLocaleString()}`;
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
// When that happens, browsers often surface it as "TypeError: Failed to fetch".
try {
const payloadText = JSON.stringify({ snapshot_json: snapshot, edit_summary: editSummary });
const payloadText = JSON.stringify({ snapshot_json: toApiEditorSnapshot(snapshot), edit_summary: editSummary });
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
if (bytes > limitBytes) {
@@ -127,13 +124,13 @@ export function useSectionCommands(options: Options) {
// If stringify fails, let API call throw a more actionable error downstream.
}
const result = await createSectionCommit(options.activeSection.id, {
const result = await createProjectCommit(options.activeSection.id, {
snapshot,
edit_summary: editSummary,
});
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
options.setSectionState(result.state);
options.setProjectState(result.state);
options.setBaselineSnapshot(sessionSnapshot);
options.setSnapshotEntities(sessionSnapshot.entities || []);
options.setSnapshotWikis(sessionSnapshot.wikis || []);
@@ -141,8 +138,7 @@ export function useSectionCommands(options: Options) {
options.setInitialData(options.editor.draft);
options.editor.clearChanges();
options.setCommitTitle("");
options.setCommitNote("");
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setProjectCommits(await fetchProjectCommits(options.activeSection.id));
options.setEntityFormStatus("Đã tạo commit.");
} catch (err) {
if (err instanceof ApiError) {
@@ -158,26 +154,26 @@ export function useSectionCommands(options: Options) {
}, [options]);
const openSelectedSection = useCallback(async () => {
const sectionId = options.selectedSectionId.trim();
if (!sectionId) {
options.setEntityStatus("Hãy chọn section để mở.");
const projectId = options.selectedProjectId.trim();
if (!projectId) {
options.setEntityStatus("Hãy chọn project để mở.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Mở project khác sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
await openSectionForEditing(sectionId);
options.setEntityStatus("Đã mở section để chỉnh sửa.");
await openSectionForEditing(projectId);
options.setEntityStatus("Đã mở project để chỉnh sửa.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
options.setEntityStatus(`Mở project thất bại: ${err.body}`);
} else {
options.setEntityStatus("Mở section thất bại.");
options.setEntityStatus("Mở project thất bại.");
}
} finally {
options.setIsOpeningSection(false);
@@ -187,40 +183,40 @@ export function useSectionCommands(options: Options) {
const createAndOpenSection = useCallback(async () => {
const title = options.newSectionTitle.trim();
if (!title) {
options.setEntityStatus("Tên section là bắt buộc.");
options.setEntityStatus("Tên project là bắt buộc.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Tạo project mới sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
const section = await createSection({
const project = await createProject({
title,
description: null,
});
const sections = await fetchSections();
options.setAvailableSections(sections);
const projects = await fetchProjects();
options.setAvailableSections(projects);
options.setNewSectionTitle("");
await openSectionForEditing(section.id);
options.setEntityStatus("Đã tạo và mở section mới.");
await openSectionForEditing(project.id);
options.setEntityStatus("Đã tạo và mở project mới.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
options.setEntityStatus(`Tạo project thất bại: ${err.body}`);
} else {
options.setEntityStatus("Tạo section thất bại.");
options.setEntityStatus("Tạo project thất bại.");
}
} finally {
options.setIsOpeningSection(false);
}
}, [openSectionForEditing, options]);
const submitCurrentSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState?.head_commit_id) {
options.setEntityStatus("Section hiện tại chưa có head để submit.");
const submitCurrentSection = useCallback(async (content: string) => {
if (!options.activeSection || !options.projectState?.head_commit_id) {
options.setEntityStatus("Project hiện tại chưa có head để submit.");
return;
}
if (options.pendingSaveCount > 0) {
@@ -231,7 +227,7 @@ export function useSectionCommands(options: Options) {
options.setIsSubmitting(true);
options.setEntityStatus(null);
try {
const submission = await submitSection(options.activeSection.id);
const submission = await submitSection(options.activeSection.id, content);
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
} catch (err) {
if (err instanceof ApiError) {
@@ -245,8 +241,8 @@ export function useSectionCommands(options: Options) {
}, [options]);
const restoreCommit = useCallback(async (commitId: string) => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
if (!options.activeSection || !options.projectState) {
options.setEntityStatus("Chưa mở được project editor.");
return;
}
if (options.pendingSaveCount > 0) {
@@ -259,8 +255,8 @@ export function useSectionCommands(options: Options) {
try {
// FE-only restore: load snapshot from selected commit and apply to editor state.
// Do NOT move project's head commit on backend.
const commits = await fetchSectionCommits(options.activeSection.id);
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
const commits = await fetchProjectCommits(options.activeSection.id);
const target = commits.find((c: ProjectCommit) => c.id === commitId) || null;
if (!target) {
options.setEntityStatus("Không tìm thấy commit để restore.");
return;
@@ -275,11 +271,11 @@ export function useSectionCommands(options: Options) {
options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null);
options.setSelectedFeatureIds([]);
options.setEntityFormStatus(null);
// Refresh commits list for UI, but keep sectionState/head as-is.
options.setSectionCommits(commits);
// Refresh commits list for UI, but keep projectState/head as-is.
options.setProjectCommits(commits);
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
} catch (err) {
if (err instanceof ApiError) {
+4 -3
View File
@@ -1,4 +1,4 @@
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions";
export type EditorMode =
| "idle"
@@ -7,7 +7,8 @@ export type EditorMode =
| "add-point"
| "add-line"
| "add-path"
| "add-circle";
| "add-circle"
| "replay";
export type TimelineRange = {
min: number;
@@ -38,4 +39,4 @@ export type CreatedEntitySummary = {
name: string;
};
export type GeometryPreset = EntityGeometryPreset;
export type { GeometryPreset };
@@ -2,7 +2,7 @@ import { useState } from "react";
import {
BackgroundLayerVisibility,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/backgroundLayers";
} from "@/uhm/lib/map/styles/backgroundLayers";
export function useBackgroundSessionState() {
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
@@ -14,8 +14,8 @@ export function useEntitySessionState() {
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
// Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState<string | null>(null);
// Feature đang được chọn để thao tác bind entities/metadata.
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
// Features đang được chọn để thao tác bind entities/metadata.
const [selectedFeatureIds, setSelectedFeatureIds] = useState<FeatureId[]>([]);
// Form tạo entity mới (độc lập).
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
@@ -50,8 +50,8 @@ export function useEntitySessionState() {
setSnapshotEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,
setSelectedFeatureId,
selectedFeatureIds,
setSelectedFeatureIds,
entityForm,
setEntityForm,
selectedGeometryEntityIds,
@@ -1,15 +1,15 @@
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
import type { EditorSnapshot, Project, ProjectCommit, ProjectState } from "@/uhm/types/projects";
type Options = {
defaultEditorUserId: string;
};
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
type SectionTask = "idle" | "saving" | "submitting" | "opening-project";
export function useSectionSessionState(options: Options) {
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
export function useProjectSessionState(options: Options) {
// Single state machine cho các tác vụ async của project (saving/submitting/opening).
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
setSectionTask((prev) => {
@@ -22,7 +22,7 @@ export function useSectionSessionState(options: Options) {
const isSaving = sectionTask === "saving";
const isSubmitting = sectionTask === "submitting";
const isOpeningSection = sectionTask === "opening-section";
const isOpeningSection = sectionTask === "opening-project";
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("saving", next);
}, [setTaskFlag]);
@@ -30,27 +30,25 @@ export function useSectionSessionState(options: Options) {
setTaskFlag("submitting", next);
}, [setTaskFlag]);
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("opening-section", next);
setTaskFlag("opening-project", next);
}, [setTaskFlag]);
// Danh sách sections để user chọn mở.
const [availableSections, setAvailableSections] = useState<Section[]>([]);
// Section ID đang được chọn trong dropdown.
const [selectedSectionId, setSelectedSectionId] = useState("");
// Title section mới (để create).
// Danh sách projects để user chọn mở.
const [availableSections, setAvailableSections] = useState<Project[]>([]);
// Project ID đang được chọn trong dropdown.
const [selectedProjectId, setSelectedProjectId] = useState("");
// Title project mới (để create).
const [newSectionTitle, setNewSectionTitle] = useState("");
// Input title cho commit.
const [commitTitle, setCommitTitle] = useState("");
// Input note cho commit.
const [commitNote, setCommitNote] = useState("");
// User ID dùng để gắn vào commit/submit/lock.
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
// Section đang mở để edit (null nếu chưa mở).
const [activeSection, setActiveSection] = useState<Section | null>(null);
// Trạng thái section (version/head/status/lock).
const [sectionState, setSectionState] = useState<SectionState | null>(null);
// Danh sách commits của section đang mở.
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
// Project đang mở để edit (null nếu chưa mở).
const [activeSection, setActiveSection] = useState<Project | null>(null);
// Trạng thái project (version/head/status/lock).
const [projectState, setProjectState] = useState<ProjectState | null>(null);
// Danh sách commits của project đang mở.
const [sectionCommits, setProjectCommits] = useState<ProjectCommit[]>([]);
// Baseline snapshot currently loaded for this editor session.
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
@@ -63,22 +61,20 @@ export function useSectionSessionState(options: Options) {
setIsOpeningSection,
availableSections,
setAvailableSections,
selectedSectionId,
setSelectedSectionId,
selectedProjectId,
setSelectedProjectId,
newSectionTitle,
setNewSectionTitle,
commitTitle,
setCommitTitle,
commitNote,
setCommitNote,
editorUserIdInput,
setEditorUserIdInput,
activeSection,
setActiveSection,
sectionState,
setSectionState,
projectState,
setProjectState,
sectionCommits,
setSectionCommits,
setProjectCommits,
baselineSnapshot,
setBaselineSnapshot,
};
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
import { clampYearValue } from "@/uhm/lib/timeline";
import { clampYearValue } from "@/uhm/lib/utils/timeline";
type Options = {
currentYear: number;
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
export function useWikiSessionState() {
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
+53 -20
View File
@@ -1,12 +1,12 @@
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
import type { EditorSnapshot, Project } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
type UnknownRecord = Record<string, unknown>;
@@ -86,12 +86,15 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const rest: UnknownRecord = { ...g };
delete rest.ref;
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
delete rest.geo_type;
return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id,
source,
operation,
type: typeKey,
};
})
: undefined;
@@ -210,30 +213,39 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
for (const feature of cloned.features) {
const gid = String(feature.properties.id);
const entity_ids = byGeom.get(gid) || [];
const p = feature.properties as unknown as UnknownRecord;
const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
if (existingTypeKey) p.type = existingTypeKey;
if (entity_ids.length || hasLinks) {
const props = feature.properties as unknown as UnknownRecord;
props.entity_ids = entity_ids;
props.entity_id = entity_ids[0] || null;
p.entity_ids = entity_ids;
p.entity_id = entity_ids[0] || null;
// Generate denormalized names for UI/map usage.
const primaryId = entity_ids[0] || null;
const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : "";
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
props.entity_name = primaryName || null;
props.entity_names = names;
p.entity_name = primaryName || null;
p.entity_names = names;
}
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
const geo = geometryById.get(gid) || null;
if (geo) {
const p = feature.properties as unknown as UnknownRecord;
// type (semantic key) is derived from geometries[].type (numeric code in string form).
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN;
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null);
const geoRecord = geo as unknown as UnknownRecord;
// type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version.
const typeKey = normalizeGeoTypeKey(geoRecord.type)
|| normalizeGeoTypeKey(geoRecord.geo_type)
|| existingTypeKey
|| fallbackTypeKey;
if (typeKey) p.type = typeKey;
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
} else if (!existingTypeKey) {
p.type = fallbackTypeKey;
}
}
return cloned;
@@ -251,7 +263,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
}
export function buildEditorSnapshot(options: {
section: Section;
project: Project;
draft: FeatureCollection;
changes: Change[];
snapshotEntities: EntitySnapshot[];
@@ -375,14 +387,12 @@ export function buildEditorSnapshot(options: {
? "update"
: "reference";
const bbox = getFeatureBBox(feature);
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
const typeCode = typeKeyToGeoTypeCode(typeKey);
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
return {
id,
operation,
source: "inline",
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string.
type: String(typeCode ?? 0),
type: typeKey,
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
@@ -600,6 +610,29 @@ export function buildEditorSnapshot(options: {
};
}
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot;
if (Array.isArray(cloned.geometries)) {
cloned.geometries = cloned.geometries.map((geometry) => {
const row = { ...(geometry as unknown as UnknownRecord) };
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
delete row.geo_type;
if (typeKey) {
const typeCode = typeKeyToGeoTypeCode(typeKey);
row.type = typeCode == null ? null : String(typeCode);
} else if ("type" in row) {
row.type = null;
}
return row as unknown as GeometrySnapshot;
});
}
return cloned;
}
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
const seen = new Set<string>();
const deduped: GeometryEntitySnapshot[] = [];
@@ -659,7 +692,7 @@ export function getDefaultTypeIdForFeature(feature: Feature): string {
if (preset === "line") return "defense_line";
if (preset === "point") return "city";
if (preset === "circle-area") return "war";
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
if (preset === "polygon") return DEFAULT_GEOMETRY_TYPE_ID;
const geometryType = feature.geometry.type;
if (geometryType === "LineString" || geometryType === "MultiLineString") {
@@ -668,7 +701,7 @@ export function getDefaultTypeIdForFeature(feature: Feature): string {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "city";
}
return DEFAULT_ENTITY_TYPE_ID;
return DEFAULT_GEOMETRY_TYPE_ID;
}
export function normalizeFeatureEntityIds(feature: Feature): string[] {
@@ -2,7 +2,7 @@ import { useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
import { useProjectSessionState } from "@/uhm/lib/editor/session/useProjectSessionState";
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
@@ -24,10 +24,10 @@ type Options = {
export function useEditorSessionState(options: Options) {
// Mode thao tác map/editor hiện tại.
const [mode, setMode] = useState<EditorMode>("idle");
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot).
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
const section = useSectionSessionState({
const project = useProjectSessionState({
defaultEditorUserId: options.defaultEditorUserId,
});
const entity = useEntitySessionState();
@@ -43,7 +43,7 @@ export function useEditorSessionState(options: Options) {
setMode,
initialData,
setInitialData,
...section,
...project,
...entity,
...timeline,
...background,
@@ -5,13 +5,13 @@ import type {
FeatureProperties,
Geometry,
} from "@/uhm/types/geo";
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff";
import { buildInitialMap, deepClone, diffDraftToInitial, geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
@@ -25,6 +25,11 @@ type SnapshotUndoApi = {
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
};
type FeaturePropertiesPatch = {
id: FeatureProperties["id"];
patch: Partial<FeatureProperties>;
};
// State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI
// - changes: map các thay đổi chờ lưu
@@ -86,19 +91,32 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
}
case "snapshot_entities": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntities(deepClone(action.prev));
const prev = deepClone(action.prev);
snapshotUndo.snapshotEntitiesRef.current = prev;
snapshotUndo.setSnapshotEntities(prev);
return true;
}
case "snapshot_wikis": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotWikis(deepClone(action.prev));
const prev = deepClone(action.prev);
snapshotUndo.snapshotWikisRef.current = prev;
snapshotUndo.setSnapshotWikis(prev);
return true;
}
case "snapshot_entity_wiki": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev));
const prev = deepClone(action.prev);
snapshotUndo.snapshotEntityWikiLinksRef.current = prev;
snapshotUndo.setSnapshotEntityWikiLinks(prev);
return true;
}
case "group": {
let applied = true;
for (let i = action.actions.length - 1; i >= 0; i -= 1) {
applied = applyUndoAction(action.actions[i]) && applied;
}
return applied;
}
default:
return false;
}
@@ -129,6 +147,51 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
pushUndo({ type: "create", id: featureClone.properties.id });
}
function createFeatureWithSnapshotEntities(
feature: Feature,
nextEntities: SetStateAction<EntitySnapshot[]>,
label = "Import geometry"
) {
const featureClone = deepClone(feature);
const undoActions: UndoAction[] = [];
if (snapshotUndo) {
const prevEntities = snapshotUndo.snapshotEntitiesRef.current || [];
const prevEntitiesClone = deepClone(prevEntities);
const computedEntities = typeof nextEntities === "function"
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
: nextEntities;
let entitiesChanged = true;
try {
entitiesChanged = JSON.stringify(prevEntities) !== JSON.stringify(computedEntities);
} catch {
entitiesChanged = true;
}
if (entitiesChanged) {
const computedEntitiesClone = deepClone(computedEntities);
undoActions.push({
type: "snapshot_entities",
label: "Cập nhật entities",
prev: prevEntitiesClone,
});
snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone;
snapshotUndo.setSnapshotEntities(computedEntitiesClone);
}
}
undoActions.push({ type: "create", id: featureClone.properties.id });
pushUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label, actions: undoActions }
);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, featureClone],
});
}
function patchFeatureProperties(
id: FeatureProperties["id"],
patch: Partial<FeatureProperties>
@@ -154,12 +217,63 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function patchFeaturePropertiesBatch(
patches: FeaturePropertiesPatch[],
label = "Cập nhật nhiều geometry"
) {
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) {
if (!item) continue;
const prev = mergedPatches.get(item.id) || {};
mergedPatches.set(item.id, {
...prev,
...deepClone(item.patch),
});
}
if (!mergedPatches.size) return;
const nextFeatures = [...draftRef.current.features];
const undoActions: UndoAction[] = [];
for (const [id, patch] of mergedPatches.entries()) {
const idx = nextFeatures.findIndex((feature) => feature.properties.id === id);
if (idx === -1) continue;
const prevProperties = deepClone(nextFeatures[idx].properties);
const nextProperties = {
...nextFeatures[idx].properties,
...deepClone(patch),
};
if (JSON.stringify(prevProperties) === JSON.stringify(nextProperties)) {
continue;
}
nextFeatures[idx] = {
...nextFeatures[idx],
properties: nextProperties,
};
undoActions.push({ type: "properties", id, prevProperties });
}
if (!undoActions.length) return;
pushUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label, actions: undoActions }
);
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const prevFeature = draftRef.current.features[idx];
const prevGeometry = deepClone(prevFeature.geometry);
if (geometryEquals(prevGeometry, newGeometry)) {
return;
}
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...prevFeature,
@@ -201,20 +315,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật entities"
) => {
if (!snapshotUndo) return;
snapshotUndo.setSnapshotEntities((prev) => {
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (changed) {
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
}
return computed;
});
const prev = snapshotUndo.snapshotEntitiesRef.current || [];
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
snapshotUndo.snapshotEntitiesRef.current = computedClone;
snapshotUndo.setSnapshotEntities(computedClone);
}, [pushUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback((
@@ -222,20 +337,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật wikis"
) => {
if (!snapshotUndo) return;
snapshotUndo.setSnapshotWikis((prev) => {
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (changed) {
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
}
return computed;
});
const prev = snapshotUndo.snapshotWikisRef.current || [];
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prevClone) : next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
snapshotUndo.snapshotWikisRef.current = computedClone;
snapshotUndo.setSnapshotWikis(computedClone);
}, [pushUndo, snapshotUndo]);
const setSnapshotEntityWikiLinksUndoable = useCallback((
@@ -243,22 +359,23 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật entity-wiki"
) => {
if (!snapshotUndo) return;
snapshotUndo.setSnapshotEntityWikiLinks((prev) => {
const prevClone = deepClone(prev);
const computed = typeof next === "function"
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev)
: next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (changed) {
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
}
return computed;
});
const prev = snapshotUndo.snapshotEntityWikiLinksRef.current || [];
const prevClone = deepClone(prev);
const computed = typeof next === "function"
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevClone)
: next;
let changed = true;
try {
changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch {
changed = true;
}
if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone;
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
}, [pushUndo, snapshotUndo]);
return {
@@ -267,7 +384,9 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
undoStack,
changeCount,
createFeature,
createFeatureWithSnapshotEntities,
patchFeatureProperties,
patchFeaturePropertiesBatch,
updateFeature,
deleteFeature,
undo,
+1 -2
View File
@@ -1,5 +1,3 @@
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
export const POINT_ICON_URL = "/point.png";
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
export const MAP_MIN_ZOOM = 2;
@@ -10,4 +8,5 @@ export const RASTER_BASE_LAYER_ID = "raster-base-layer";
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
export const POLYGON_LABEL_SOURCE_ID = "polygon-labels";
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
@@ -1,8 +1,8 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { buildCircleRing, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
const EARTH_RADIUS_METERS = 6371008.8;
const CIRCLE_SEGMENTS = 72;
const MIN_RADIUS_METERS = 1;
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
@@ -123,6 +123,8 @@ export function initCircle(
onComplete({
type: "Polygon",
coordinates: [ring],
circle_center: center,
circle_radius: radiusMeters,
});
resetDrawingState();
};
@@ -163,85 +165,3 @@ export function initCircle(
cancel: resetDrawingState,
};
}
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
function buildCircleRing(
center: [number, number],
radiusMeters: number,
segments: number
): [number, number][] {
const ring: [number, number][] = [];
for (let i = 0; i <= segments; i += 1) {
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
}
return ring;
}
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]);
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
const h =
sinLat * sinLat +
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
}
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
function destinationPoint(
center: [number, number],
distance: number,
bearingDeg: number
): [number, number] {
const lat1 = toRad(center[1]);
const lng1 = toRad(center[0]);
const bearing = toRad(bearingDeg);
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinAngular = Math.sin(angularDistance);
const cosAngular = Math.cos(angularDistance);
const sinLat2 =
sinLat1 * cosAngular +
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
}
// Chuẩn hóa kinh độ về miền [-180, 180].
function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
if (normalized === -180) normalized = 180;
return normalized;
}
// Kẹp giá trị trong đoạn [min, max].
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Đổi đơn vị góc từ độ sang radian.
function toRad(value: number): number {
return (value * Math.PI) / 180; // Đổi độ sang radian.
}
// Đổi đơn vị góc từ radian sang độ.
function toDeg(value: number): number {
return (value * 180) / Math.PI; // Đổi radian sang độ.
}
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
export function initDrawing(
@@ -57,7 +58,13 @@ export function initDrawing(
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw") return;
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
let lngLat = e.lngLat;
// Dùng Shift (hoặc Alt nếu Shift bị maplibre chiếm dụng) để snap
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
}
coords.push([lngLat.lng, lngLat.lat] as [number, number]);
update(coords);
}
@@ -65,9 +72,14 @@ export function initDrawing(
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw" || coords.length === 0) return;
let lngLat = e.lngLat;
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
}
const preview: [number, number][] = [
...coords,
[e.lngLat.lng, e.lngLat.lat] as [number, number],
[lngLat.lng, lngLat.lat] as [number, number],
];
update(preview);
}
@@ -109,11 +121,17 @@ export function initDrawing(
}
}
// Tắt tính năng box zoom và double click zoom để Shift không bị lỗi
map.boxZoom.disable();
map.doubleClickZoom.disable();
map.on("click", onClick);
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
const cleanup = () => {
map.boxZoom.enable();
map.doubleClickZoom.enable();
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
@@ -1,10 +1,14 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
export type EditingHandle = {
id: string | number;
ring: [number, number][];
original: Geometry;
isCircle?: boolean;
circleCenter?: [number, number];
circleRadius?: number;
};
export type EditingAPI = {
@@ -40,26 +44,62 @@ export function createEditingEngine(options: {
const map = mapRef.current;
if (!editing || !map) return;
const closedRing = [...editing.ring, editing.ring[0]];
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
let shape: GeoJSON.FeatureCollection<GeoJSON.Polygon>;
let handles: GeoJSON.FeatureCollection<GeoJSON.Point>;
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
})),
};
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
const closedRing = [...ring, ring[0]];
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
// Circle handles: 0 = center, 1 = radius control
const radiusHandlePoint = destinationPoint(editing.circleCenter, editing.circleRadius, 90);
handles = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: editing.circleCenter },
properties: { idx: 0, type: "center" },
},
{
type: "Feature",
geometry: { type: "Point", coordinates: radiusHandlePoint },
properties: { idx: 1, type: "radius" },
},
],
};
} else {
const closedRing = [...editing.ring, editing.ring[0]];
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
handles = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
})),
};
}
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
@@ -69,10 +109,23 @@ export function createEditingEngine(options: {
const finishEditing = () => {
const editing = editingRef.current;
if (!editing) return;
const geometry: Geometry = {
type: "Polygon",
coordinates: [[...editing.ring, editing.ring[0]]],
};
let geometry: Geometry;
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
geometry = {
type: "Polygon",
coordinates: [[...ring, ring[0]]],
circle_center: editing.circleCenter,
circle_radius: editing.circleRadius,
};
} else {
geometry = {
type: "Polygon",
coordinates: [[...editing.ring, editing.ring[0]]],
};
}
onUpdate(editing.id, geometry);
clearEditing();
};
@@ -85,15 +138,21 @@ export function createEditingEngine(options: {
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return;
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
const geom = feature.geometry as Geometry;
const coords = (geom.coordinates?.[0] ?? []) as [number, number][];
if (coords.length < 4) return;
const isCircle = !!geom.circle_center;
// remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
editingRef.current = {
id: feature.id ?? feature.properties?.id,
ring,
original: feature.geometry as Geometry,
original: geom,
isCircle,
circleCenter: geom.circle_center,
circleRadius: geom.circle_radius,
};
updateEditSources();
};
@@ -129,7 +188,20 @@ export function createEditingEngine(options: {
const editing = editingRef.current;
if (!drag || !editing) return;
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
if (drag.idx === 0) {
// Move center
editing.circleCenter = [e.lngLat.lng, e.lngLat.lat];
} else if (drag.idx === 1) {
// Change radius
editing.circleRadius = distanceMeters(editing.circleCenter, [
e.lngLat.lng,
e.lngLat.lat,
]);
}
} else {
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
}
updateEditSources();
};
@@ -166,7 +238,7 @@ export function createEditingEngine(options: {
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
if (!editingRef.current || editingRef.current.isCircle) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
const editing = editingRef.current;
@@ -1,6 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
@@ -1,6 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
@@ -1,6 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
// Khởi tạo engine thêm point bằng click đơn.
export function initPoint(
@@ -1,5 +1,5 @@
import maplibregl from "maplibre-gl";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect(
@@ -7,25 +7,17 @@ export function initSelect(
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectId?: (id: string | number | null) => void
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void
) {
const SELECTABLE_LAYERS = [
"countries-fill",
"countries-line",
"routes-line",
"routes-path-arrow-fill",
"routes-path-arrow-line",
"routes-path-hit",
"places-circle",
"places-symbol",
] as const;
const FEATURE_STATE_SOURCES = [
"countries",
"places",
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit);
const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -35,7 +27,7 @@ export function initSelect(
selectedIds.forEach((id) => setSelectionStateForId(id, false));
selectedIds.clear();
if (emit) {
onSelectId?.(null);
onSelectIds?.([]);
}
}
@@ -52,18 +44,18 @@ export function initSelect(
// Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false);
selectedIds.delete(id);
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
onSelectIds?.(Array.from(selectedIds));
return;
}
setSelectionStateForId(id, true);
selectedIds.add(id);
onSelectId?.(selectedIds.size === 1 ? id : null);
onSelectIds?.(Array.from(selectedIds));
}
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
@@ -83,11 +75,12 @@ export function initSelect(
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
@@ -114,7 +107,7 @@ export function initSelect(
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) {
map.getCanvas().style.cursor = "";
@@ -129,7 +122,11 @@ export function initSelect(
}
function getSelectableLayers(): string[] {
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
const style = map.getStyle();
if (!style || !style.layers) return [];
return style.layers
.filter((layer) => "source" in layer && FEATURE_STATE_SOURCES.includes(layer.source as any))
.map((layer) => layer.id);
}
function setSelectionStateForId(id: string | number, selected: boolean) {
@@ -223,6 +220,17 @@ export function initSelect(
hasMenuItems = true;
}
if (
selectedCount === 1 &&
onReplayEdit
) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
hasMenuItems = true;
}
}
if (onDelete) {
menu.appendChild(
createItem(
+82
View File
@@ -0,0 +1,82 @@
import maplibregl from "maplibre-gl";
const SNAP_THRESHOLD_PX = 15;
export function snapToNearestGeometry(
map: maplibregl.Map,
lngLat: maplibregl.LngLat,
pointPx: maplibregl.Point
): maplibregl.LngLat {
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[pointPx.x - SNAP_THRESHOLD_PX, pointPx.y - SNAP_THRESHOLD_PX],
[pointPx.x + SNAP_THRESHOLD_PX, pointPx.y + SNAP_THRESHOLD_PX],
];
const features = map.queryRenderedFeatures(bbox);
let nearestDist = Infinity;
let nearestLngLat: maplibregl.LngLat | null = null;
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
};
// Tìm điểm gần nhất trên đoạn thẳng [a, b] so với điểm p
const getClosestPointOnSegment = (p: maplibregl.Point, a: maplibregl.Point, b: maplibregl.Point): maplibregl.Point => {
const atob = { x: b.x - a.x, y: b.y - a.y };
const atop = { x: p.x - a.x, y: p.y - a.y };
const lenSq = atob.x * atob.x + atob.y * atob.y;
if (lenSq === 0) return new maplibregl.Point(a.x, a.y);
let t = (atop.x * atob.x + atop.y * atob.y) / lenSq;
t = Math.max(0, Math.min(1, t));
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
};
const processLineString = (line: number[][]) => {
if (!line || line.length < 2) return;
for (let i = 0; i < line.length - 1; i++) {
const p1LngLat = new maplibregl.LngLat(line[i][0], line[i][1]);
const p2LngLat = new maplibregl.LngLat(line[i + 1][0], line[i + 1][1]);
const p1 = map.project(p1LngLat);
const p2 = map.project(p2LngLat);
const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
const distSq = getDistSq(pointPx, closestPx);
if (distSq < nearestDist && distSq <= SNAP_THRESHOLD_PX ** 2) {
nearestDist = distSq;
nearestLngLat = map.unproject(closestPx);
}
}
};
for (const feature of features) {
if (!feature.geometry) continue;
// Bỏ qua các layer preview hoặc edit để không tự snap vào nét đang vẽ dở.
if (feature.layer.id.includes("preview") || feature.layer.id.includes("edit-")) {
continue;
}
const type = feature.geometry.type;
if (type === "GeometryCollection") continue;
const coords = (feature.geometry as any).coordinates;
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
if (type === "Polygon") {
for (const ring of coords) processLineString(ring);
} else if (type === "MultiPolygon") {
for (const poly of coords) {
for (const ring of poly) processLineString(ring);
}
} else if (type === "LineString") {
processLineString(coords);
} else if (type === "MultiLineString") {
for (const line of coords) processLineString(line);
}
}
return nearestLngLat || lngLat;
}
+79
View File
@@ -0,0 +1,79 @@
const EARTH_RADIUS_METERS = 6371008.8;
// Đổi đơn vị góc từ độ sang radian.
export function toRad(value: number): number {
return (value * Math.PI) / 180;
}
// Đổi đơn vị góc từ radian sang độ.
export function toDeg(value: number): number {
return (value * 180) / Math.PI;
}
// Kẹp giá trị trong đoạn [min, max].
export function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Chuẩn hóa kinh độ về miền [-180, 180].
export function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180;
if (normalized === -180) normalized = 180;
return normalized;
}
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
export function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]);
const dLat = lat2 - lat1;
const dLng = toRad(b[0] - a[0]);
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
return EARTH_RADIUS_METERS * c;
}
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
export function destinationPoint(
center: [number, number],
distance: number,
bearingDeg: number
): [number, number] {
const lat1 = toRad(center[1]);
const lng1 = toRad(center[0]);
const bearing = toRad(bearingDeg);
const angularDistance = distance / EARTH_RADIUS_METERS;
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinAngular = Math.sin(angularDistance);
const cosAngular = Math.cos(angularDistance);
const sinLat2 = sinLat1 * cosAngular + cosLat1 * sinAngular * Math.cos(bearing);
const lat2 = Math.asin(clamp(sinLat2, -1, 1));
const y = Math.sin(bearing) * sinAngular * cosLat1;
const x = cosAngular - sinLat1 * Math.sin(lat2);
const lng2 = lng1 + Math.atan2(y, x);
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
}
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
export function buildCircleRing(
center: [number, number],
radiusMeters: number,
segments: number = 72
): [number, number][] {
const ring: [number, number][] = [];
for (let i = 0; i <= segments; i += 1) {
const bearingDeg = (i / segments) * 360;
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
}
return ring;
}
@@ -1,4 +1,4 @@
import rows from "@/uhm/lib/geoTypeMap.json";
import rows from "@/uhm/lib/map/geo/geoTypeMap.json";
export type GeoTypeMapRow = {
type_key: string;
@@ -7,6 +7,14 @@ export type GeoTypeMapRow = {
const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[];
export const GEO_TYPE_KEYS: string[] = Array.from(
new Set(
MAP_ROWS
.map((row) => (typeof row?.type_key === "string" ? row.type_key.trim().toLowerCase() : ""))
.filter((key) => key.length > 0)
)
);
const CODE_BY_KEY = new Map<string, number>();
const KEY_BY_CODE = new Map<number, string>();
@@ -32,3 +40,19 @@ export function geoTypeCodeToTypeKey(code: number | null | undefined): string |
return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
}
export function normalizeGeoTypeKey(value: unknown): string | null {
if (typeof value === "number") {
return geoTypeCodeToTypeKey(value);
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (!normalized.length) return null;
if (/^-?\d+$/.test(normalized)) {
return geoTypeCodeToTypeKey(Number(normalized));
}
return normalized;
}
@@ -1,27 +1,27 @@
export type EntityTypeGroupId =
export type GeometryTypeGroupId =
| "line"
| "polygon"
| "circle"
| "point";
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
export type EntityTypeGroup = {
id: EntityTypeGroupId;
export type GeometryTypeGroup = {
id: GeometryTypeGroupId;
label: string;
geometryLabel: string;
description: string;
};
export type EntityTypeOption = {
export type GeometryTypeOption = {
value: string;
label: string;
groupId: EntityTypeGroupId;
groupId: GeometryTypeGroupId;
groupLabel: string;
geometryPreset: EntityGeometryPreset;
geometryPreset: GeometryPreset;
};
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
export const GEOMETRY_TYPE_GROUPS: GeometryTypeGroup[] = [
{
id: "line",
label: "line - Tuyến",
@@ -48,18 +48,18 @@ export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
},
];
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
line: ENTITY_TYPE_GROUPS[0],
polygon: ENTITY_TYPE_GROUPS[1],
circle: ENTITY_TYPE_GROUPS[2],
point: ENTITY_TYPE_GROUPS[3],
const GROUP_BY_ID: Record<GeometryTypeGroupId, GeometryTypeGroup> = {
line: GEOMETRY_TYPE_GROUPS[0],
polygon: GEOMETRY_TYPE_GROUPS[1],
circle: GEOMETRY_TYPE_GROUPS[2],
point: GEOMETRY_TYPE_GROUPS[3],
};
const RAW_ENTITY_TYPE_OPTIONS: Array<{
const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
value: string;
label: string;
groupId: EntityTypeGroupId;
geometryPreset: EntityGeometryPreset;
groupId: GeometryTypeGroupId;
geometryPreset: GeometryPreset;
}> = [
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
@@ -94,29 +94,29 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
];
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
export const GEOMETRY_TYPE_OPTIONS: GeometryTypeOption[] = RAW_GEOMETRY_TYPE_OPTIONS.map((item) => ({
...item,
groupLabel: GROUP_BY_ID[item.groupId].label,
}));
export const DEFAULT_ENTITY_TYPE_ID = "country";
export const DEFAULT_GEOMETRY_TYPE_ID = "country";
// Gom option theo group để render select phân nhóm.
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
id: EntityTypeGroupId;
export function groupGeometryTypeOptions(options: GeometryTypeOption[] = GEOMETRY_TYPE_OPTIONS): Array<{
id: GeometryTypeGroupId;
label: string;
geometryLabel: string;
description: string;
options: EntityTypeOption[];
options: GeometryTypeOption[];
}> {
return ENTITY_TYPE_GROUPS.map((group) => ({
return GEOMETRY_TYPE_GROUPS.map((group) => ({
...group,
options: options.filter((option) => option.groupId === group.id),
})).filter((group) => group.options.length > 0);
}
// Tìm option theo type id, trả null nếu không tồn tại.
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
export function findGeometryTypeOption(typeId: string | null | undefined): GeometryTypeOption | null {
if (!typeId) return null;
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
return GEOMETRY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
}
+74
View File
@@ -0,0 +1,74 @@
import maplibregl from "maplibre-gl";
export const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
export { ensurePointGeotypeIcons } from "./shared/pointStyle";
import { getDefenseLineLayers } from "./geotypes/defense_line";
import { getAttackRouteLayers } from "./geotypes/attack_route";
import { getRetreatRouteLayers } from "./geotypes/retreat_route";
import { getInvasionRouteLayers } from "./geotypes/invasion_route";
import { getMigrationRouteLayers } from "./geotypes/migration_route";
import { getRefugeeRouteLayers } from "./geotypes/refugee_route";
import { getTradeRouteLayers } from "./geotypes/trade_route";
import { getShippingRouteLayers } from "./geotypes/shipping_route";
import { getCountryLayers } from "./geotypes/country";
import { getStateLayers } from "./geotypes/state";
import { getEmpireLayers } from "./geotypes/empire";
import { getKingdomLayers } from "./geotypes/kingdom";
import { getWarLayers } from "./geotypes/war";
import { getBattleLayers } from "./geotypes/battle";
import { getCivilizationLayers } from "./geotypes/civilization";
import { getRebellionZoneLayers } from "./geotypes/rebellion_zone";
import { getPersonDeathplaceLayers } from "./geotypes/person_deathplace";
import { getPersonBirthplaceLayers } from "./geotypes/person_birthplace";
import { getPersonActivityLayers } from "./geotypes/person_activity";
import { getTempleLayers } from "./geotypes/temple";
import { getCapitalLayers } from "./geotypes/capital";
import { getCityLayers } from "./geotypes/city";
import { getFortressLayers } from "./geotypes/fortress";
import { getCastleLayers } from "./geotypes/castle";
import { getRuinLayers } from "./geotypes/ruin";
import { getPortLayers } from "./geotypes/port";
import { getBridgeLayers } from "./geotypes/bridge";
import { getLineLabelLayers } from "./shared/lineLabels";
import { getPolygonLabelLayers } from "./shared/polygonLabels";
import { LayerSpecification } from "maplibre-gl";
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId),
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId),
...getWarLayers(sourceId, pathArrowSourceId, pointSourceId),
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
...getTempleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCapitalLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCityLayers(sourceId, pathArrowSourceId, pointSourceId),
...getFortressLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCastleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRuinLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPortLayers(sourceId, pathArrowSourceId, pointSourceId),
...getBridgeLayers(sourceId, pathArrowSourceId, pointSourceId)
];
}
export function getAllGeotypeLabelLayers(polygonLabelSourceId: string, lineSourceId: string): LayerSpecification[] {
return [
...getPolygonLabelLayers(polygonLabelSourceId),
...getLineLabelLayers(lineSourceId),
];
}
@@ -0,0 +1,13 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getAttackRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
typeId: "attack_route",
color: "#ef4444",
strokeColor: "#7f1d1d",
width: { z1: 2.6, z4: 3.8, z6: 5 },
arrowOpacity: 0.9,
});
}
+14
View File
@@ -0,0 +1,14 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getBattleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
void pointSourceId;
return buildPolygonGeotypeLayers(sourceId, {
typeId: "battle",
fillColor: "#f43f5e",
strokeColor: "#9f1239",
fillOpacity: 0.3,
strokeWidth: { z1: 1.5, z4: 2.2, z6: 3 },
});
}
@@ -0,0 +1,8 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getBridgeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
return buildPointGeotypeLayers("bridge", pointSourceId!);
}
@@ -0,0 +1,8 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCapitalLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
return buildPointGeotypeLayers("capital", pointSourceId!);
}
@@ -0,0 +1,8 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCastleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
return buildPointGeotypeLayers("castle", pointSourceId!);
}
+8
View File
@@ -0,0 +1,8 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
return buildPointGeotypeLayers("city", pointSourceId!);
}
@@ -0,0 +1,13 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getCivilizationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
void pointSourceId;
return buildPolygonGeotypeLayers(sourceId, {
typeId: "civilization",
fillColor: "#14b8a6",
strokeColor: "#134e4a",
fillOpacity: 0.34,
});
}

Some files were not shown because too many files have changed in this diff Show More