Compare commits
95 Commits
f5514b8fb5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5ad996c0 | |||
| 59de951edd | |||
| 99c3efe678 | |||
| e0f89df21c | |||
| ffd821dbd6 | |||
| b821af9747 | |||
| 4f9f2cd854 | |||
| 495162ff43 | |||
| c16f4f773a | |||
| 3fff4b7b20 | |||
| 4265e84764 | |||
| 9080e1193d | |||
| 3b90549202 | |||
| 05af7f19f5 | |||
| 0462ed1ef5 | |||
| 32dabcff9c | |||
| 82eca6ee1a | |||
| 7a335f9415 | |||
| 5595bc7371 | |||
| a4c4084f9b | |||
| 44a2a437df | |||
| 74b5b615ac | |||
| 282df2bc91 | |||
| 0ce198aa5e | |||
| b051b2778f | |||
| 22d30a2469 | |||
| 43a3d75f43 | |||
| 24a5abb1f1 | |||
| f41b14349c | |||
| 501d562025 | |||
| 6d7d63a891 | |||
| 820e0b5216 | |||
| 61949e7149 | |||
| e9657a4003 | |||
| 38b6e9fca6 | |||
| 35cd174c8b | |||
| 5aee0eccb2 | |||
| 9a5dfdb2ed | |||
| 794ad2913f | |||
| 6c509a6b54 | |||
| fb816fca11 | |||
| 82064af0db | |||
| 5b13ec8d8d | |||
| 288cde5dcf | |||
| d18c29f391 | |||
| 1a77d471ad | |||
| d270d9435b | |||
| 104d62bc13 | |||
| 86ca32bc01 | |||
| 793e980c93 | |||
| a987a83280 | |||
| da4dea7f5d | |||
| c9082a9f58 | |||
| e81dd69f19 | |||
| b94f5f44cb | |||
| 0dbe26fd4e | |||
| b3d2f56797 | |||
| 4c60e2d773 | |||
| ef3766bc2a | |||
| 3d21d078cf | |||
| 184abb25b4 | |||
| 55e8f13e32 | |||
| 2a5b894a1b | |||
| 092bbec6ac | |||
| 5a0e77ebb8 | |||
| cdf3323d77 | |||
| 2a3193a3fa | |||
| 8c4a9cc85f | |||
| faf5c56219 | |||
| e403413965 | |||
| 9d04076921 | |||
| 8306543828 | |||
| de91f8129e | |||
| f38ae2c288 | |||
| 9aa61dce27 | |||
| 395eb3de47 | |||
| a98e1ac5b0 | |||
| 0ebf8e1c65 | |||
| 23b2c6f534 | |||
| c8d2415e50 | |||
| 82dfd7fa56 | |||
| 051835b9bd | |||
| 282b365287 | |||
| 3b4ff71b9a | |||
| dc6d048645 | |||
| ee468fe4fe | |||
| 8f0e912d9e | |||
| b5dcda83a9 | |||
| 457eee4ffa | |||
| 7e025fb449 | |||
| 8c0bff8082 | |||
| 2a3e908c44 | |||
| 6599d8bf21 | |||
| 194b3ad3c2 | |||
| 488eee1a25 |
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 TailAdmin
|
Copyright (c) 2026 Pregnant guide
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# FrontEndUser Technical README
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Tài liệu này mô tả phần **FrontEndUser** như một runtime web độc lập: biên dịch bằng Next.js, triển khai bằng Docker, phụ thuộc vào API backend để xử lý dữ liệu lịch sử, xác thực, media và proxy bản đồ. Nội dung ở đây không mô tả giá trị sản phẩm; nó mô tả ràng buộc hệ thống, quyết định kỹ thuật, điểm kiểm soát vận hành và số đo hiện có trong repository.
|
||||||
|
|
||||||
|
## Baseline Thực Nghiệm
|
||||||
|
|
||||||
|
Các số liệu dưới đây được đo trực tiếp tại working tree hiện tại.
|
||||||
|
|
||||||
|
```text
|
||||||
|
package name fe_admin_history_web
|
||||||
|
package version 2.2.3
|
||||||
|
runtime image node:24-alpine
|
||||||
|
production server port 3000
|
||||||
|
docker-compose host port 3014
|
||||||
|
dependencies 34
|
||||||
|
devDependencies 12
|
||||||
|
TypeScript/TSX files in src 232
|
||||||
|
Next app route files 20
|
||||||
|
TypeScript/TSX files in uhm 140
|
||||||
|
src size 3.4M
|
||||||
|
public size 43M
|
||||||
|
node_modules size 759M
|
||||||
|
.next size 941M
|
||||||
|
.next/standalone size 58M
|
||||||
|
.next/static size 5.3M
|
||||||
|
```
|
||||||
|
|
||||||
|
Diễn giải vận hành:
|
||||||
|
|
||||||
|
* Kích thước `node_modules` và `.next` không được dùng làm runtime artifact. Runtime Docker chỉ cần standalone server, static assets và `public`.
|
||||||
|
* `.next/standalone` đang ở mức 58M, thấp hơn rất nhiều so với full build directory 941M. Đây là lý do bắt buộc giữ `output: "standalone"` trong `next.config.ts`.
|
||||||
|
* 140/232 file TS/TSX nằm trong `src/uhm`; rủi ro thay đổi tập trung ở module bản đồ, editor, wiki, replay và geospatial client logic.
|
||||||
|
* `npm ls --depth=0` hiện báo một package extraneous: `@emnapi/runtime@1.9.1`. Đây không phải lỗi build mặc định, nhưng là tín hiệu cần dọn dependency tree trước khi khóa môi trường CI nghiêm ngặt.
|
||||||
|
|
||||||
|
## Runtime Boundary
|
||||||
|
|
||||||
|
FrontEndUser không sở hữu dữ liệu lõi. Nó là boundary hiển thị và tương tác.
|
||||||
|
|
||||||
|
Các trách nhiệm nằm trong frontend:
|
||||||
|
|
||||||
|
* render route bằng Next.js App Router;
|
||||||
|
* giữ state tương tác của bản đồ/editor/wiki;
|
||||||
|
* gọi API backend qua Axios/fetch;
|
||||||
|
* xử lý token phía client khi backend trả token trong payload;
|
||||||
|
* gửi cookie theo `withCredentials: true`;
|
||||||
|
* rewrite request Goong qua backend proxy;
|
||||||
|
* render MapLibre layer/source sau khi nhận manifest/style đã sanitize từ backend.
|
||||||
|
|
||||||
|
Các trách nhiệm không được đặt vào frontend:
|
||||||
|
|
||||||
|
* giữ Goong API key thật;
|
||||||
|
* quyết định quyền truy cập dữ liệu;
|
||||||
|
* xử lý refresh token như một nguồn tin cậy;
|
||||||
|
* proxy trực tiếp tới upstream map provider từ browser;
|
||||||
|
* query geospatial nặng;
|
||||||
|
* normalize dữ liệu lịch sử ở quy mô database.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Stack chính theo `package.json` và lockfile hiện tại:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Next.js 16.x
|
||||||
|
React 19.x
|
||||||
|
TypeScript 5.9.x
|
||||||
|
Tailwind CSS 4.x
|
||||||
|
MapLibre GL 5.x
|
||||||
|
Axios 1.x
|
||||||
|
Redux Toolkit 2.x
|
||||||
|
Zustand 5.x
|
||||||
|
```
|
||||||
|
|
||||||
|
Các dependency nặng đã được tách chunk trong `next.config.ts`:
|
||||||
|
|
||||||
|
* `maplibre-gl` -> chunk `maplibre`
|
||||||
|
* `react-quill-new`, `quill`, `quill-blot-formatter` -> chunk `quill`
|
||||||
|
* `apexcharts`, `react-apexcharts` -> chunk `charts`
|
||||||
|
* `@fullcalendar/*` -> chunk `calendar`
|
||||||
|
|
||||||
|
Ràng buộc ở đây là rõ: bản đồ, rich text editor, chart và calendar không được kéo vào cùng một client bundle mặc định nếu route không cần chúng.
|
||||||
|
|
||||||
|
## Configuration Contract
|
||||||
|
|
||||||
|
Frontend đọc cấu hình từ environment tại thời điểm build.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_API_URL_ROOT="https://api.uhm.io.vn"
|
||||||
|
NEXT_PUBLIC_URL_MEDIA="https://cdn.uhm.io.vn/history-app/"
|
||||||
|
NEXT_PUBLIC_HOME_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
BACKGROUND_MAP_API_KEY=
|
||||||
|
SEARCH_MAP_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
Biến đang được code client đọc trực tiếp:
|
||||||
|
|
||||||
|
* `NEXT_PUBLIC_API_URL_ROOT`
|
||||||
|
* `NEXT_PUBLIC_URL_MEDIA`
|
||||||
|
* `NEXT_PUBLIC_HOME_URL`
|
||||||
|
|
||||||
|
Quy tắc triển khai: mọi biến `NEXT_PUBLIC_*` phải đúng trước `npm run build` hoặc trước `docker build`. Đổi biến ở runtime container không đảm bảo đổi behavior phía browser vì Next.js đã inline các giá trị public vào client bundle.
|
||||||
|
|
||||||
|
## Backend Dependency Contract
|
||||||
|
|
||||||
|
`NEXT_PUBLIC_API_URL_ROOT` là contract trung tâm. Từ biến này, frontend tạo các endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<API_ROOT>/users/current
|
||||||
|
<API_ROOT>/auth/signin
|
||||||
|
<API_ROOT>/auth/refresh
|
||||||
|
<API_ROOT>/projects
|
||||||
|
<API_ROOT>/submissions
|
||||||
|
<API_ROOT>/geometries
|
||||||
|
<API_ROOT>/entities
|
||||||
|
<API_ROOT>/wikis
|
||||||
|
<API_ROOT>/battle-replays
|
||||||
|
```
|
||||||
|
|
||||||
|
Map proxy contract:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<API_ROOT>/map/proxy/tiles.goong.io/...
|
||||||
|
<API_ROOT>/api/proxy/rsapi.goong.io/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend phải thỏa các điều kiện sau:
|
||||||
|
|
||||||
|
* CORS cho phép frontend origin production.
|
||||||
|
* Cookie policy tương thích cross-origin nếu deploy khác domain.
|
||||||
|
* `/auth/refresh` hoạt động với cookie httpOnly hoặc trả access token mới theo payload mà frontend hiểu.
|
||||||
|
* Proxy Goong không trả JSON chứa `api_key`.
|
||||||
|
* Proxy Goong giữ shape đủ tương thích để frontend tiếp tục rewrite nested resource URLs.
|
||||||
|
|
||||||
|
## Map Constraint
|
||||||
|
|
||||||
|
MapLibre không được gọi trực tiếp Goong bằng URL chứa key. Flow hiện tại:
|
||||||
|
|
||||||
|
1. Frontend gọi style/source/font/tile qua backend proxy.
|
||||||
|
2. Backend gọi upstream Goong bằng server-side key.
|
||||||
|
3. Backend sanitize URL lồng bên trong response.
|
||||||
|
4. Frontend rewrite URL sạch qua `buildGoongProxyUrl`.
|
||||||
|
5. MapLibre chỉ thấy proxy URL.
|
||||||
|
|
||||||
|
Constraint quan trọng: backend không nên rewrite sẵn mọi nested URL thành `/proxy/...` nếu frontend vẫn gọi `buildGoongProxyUrl`; làm vậy có rủi ro double-proxy. Contract hiện tại yêu cầu backend trả upstream URL sạch hoặc relative URL, không trả URL đã chứa key.
|
||||||
|
|
||||||
|
## Auth Constraint
|
||||||
|
|
||||||
|
Axios instance dùng `withCredentials: true` và request interceptor gắn Bearer token nếu có token trong client storage. Response interceptor xử lý hai loại hết hạn token:
|
||||||
|
|
||||||
|
* HTTP `401`;
|
||||||
|
* response `200` nhưng body có `status: false` và message cho thấy token hết hạn/không hợp lệ.
|
||||||
|
|
||||||
|
Khi refresh thất bại với `401` hoặc `404`, frontend xóa token local và redirect về `/signin`.
|
||||||
|
|
||||||
|
Ràng buộc vận hành:
|
||||||
|
|
||||||
|
* backend không được trả message hết hạn token mơ hồ nếu muốn frontend tự refresh;
|
||||||
|
* refresh endpoint không được tạo vòng lặp interceptor;
|
||||||
|
* cookie refresh phải có domain, path, SameSite và Secure tương thích domain deploy.
|
||||||
|
|
||||||
|
## Build And Deployment
|
||||||
|
|
||||||
|
Development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev server khác port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- --port 3005
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gates cục bộ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Production bằng Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Port mapping:
|
||||||
|
|
||||||
|
```text
|
||||||
|
host:3014 -> container:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile hiện có ba lớp logic:
|
||||||
|
|
||||||
|
1. `deps`: cài dependency bằng `npm ci`.
|
||||||
|
2. `builder`: build Next.js standalone bằng `npm run build`.
|
||||||
|
3. `runner`: chạy `node server.js` từ `.next/standalone`.
|
||||||
|
|
||||||
|
Do `Dockerfile` copy `.env` vào build stage, file `.env` production phải được kiểm soát trước khi build image. Không dùng `.env.local` như nguồn cấu hình production trừ khi Dockerfile được đổi có chủ đích.
|
||||||
|
|
||||||
|
## Operational Checks
|
||||||
|
|
||||||
|
Sau khi deploy, kiểm tra theo thứ tự phụ thuộc:
|
||||||
|
|
||||||
|
1. `GET /` trả HTML và load client bundle không lỗi.
|
||||||
|
2. Browser không request trực tiếp `tiles.goong.io` hoặc `rsapi.goong.io`.
|
||||||
|
3. Map request đi qua `<API_ROOT>/map/proxy/...`.
|
||||||
|
4. Place search/reverse geocode request đi qua `<API_ROOT>/api/proxy/...`.
|
||||||
|
5. `/auth/signin` ghi được cookie hoặc trả token mà client lưu được.
|
||||||
|
6. `/auth/refresh` trả token mới khi access token hết hạn.
|
||||||
|
7. `/users/current` hoạt động sau refresh.
|
||||||
|
8. `/wiki/[slug]`, `/editor`, `/user/projects` không crash khi reload trực tiếp.
|
||||||
|
9. Media URL dưới `NEXT_PUBLIC_URL_MEDIA` trả được ảnh/video cần render.
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
Các lỗi có xác suất cao nhất khi triển khai:
|
||||||
|
|
||||||
|
* Build image bằng sai `NEXT_PUBLIC_API_URL_ROOT`: frontend deploy thành công nhưng mọi API call trỏ sai host.
|
||||||
|
* Backend CORS đúng cho API thường nhưng sai cho credentialed request: sign in được một phần, refresh/session hỏng.
|
||||||
|
* Goong proxy trả URL còn `api_key`: lộ key qua DevTools và cache layer.
|
||||||
|
* Goong proxy rewrite nested URL quá sớm: MapLibre nhận URL double-proxy và tile/font hỏng.
|
||||||
|
* Thêm dependency bản đồ/editor/chart vào shared component: tăng client bundle của route không liên quan.
|
||||||
|
* Deploy sau khi `npm install` tạo dependency tree khác lockfile: `npm ci` trong Docker/CI có thể fail hoặc build khác local.
|
||||||
|
|
||||||
|
## Change Control
|
||||||
|
|
||||||
|
Các thay đổi cần được xem như thay đổi kiến trúc, không phải chỉnh UI đơn thuần:
|
||||||
|
|
||||||
|
* đổi `NEXT_PUBLIC_API_URL_ROOT` semantics;
|
||||||
|
* đổi đường dẫn `/map/proxy` hoặc `/api/proxy`;
|
||||||
|
* đổi cơ chế refresh token;
|
||||||
|
* đưa Goong key xuống browser;
|
||||||
|
* bỏ `output: "standalone"`;
|
||||||
|
* thay MapLibre style loading từ incremental source/layer sang `map.setStyle(goongStyleJson)` trực tiếp;
|
||||||
|
* đưa `maplibre-gl`, Quill, chart hoặc calendar vào layout dùng chung.
|
||||||
|
|
||||||
|
## Measurement Commands
|
||||||
|
|
||||||
|
Dùng các lệnh này để cập nhật baseline khi cần:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
du -sh .next node_modules public src
|
||||||
|
du -sh .next/standalone .next/static
|
||||||
|
find src -type f \( -name '*.ts' -o -name '*.tsx' \) | wc -l
|
||||||
|
find src/app -type f \( -name 'page.tsx' -o -name 'layout.tsx' \) | wc -l
|
||||||
|
find src/uhm -type f \( -name '*.ts' -o -name '*.tsx' \) | wc -l
|
||||||
|
node -e "const p=require('./package.json'); console.log(Object.keys(p.dependencies||{}).length, Object.keys(p.devDependencies||{}).length)"
|
||||||
|
npm ls --depth=0
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
This project includes portions of TailAdmin.
|
||||||
|
|
||||||
|
TailAdmin
|
||||||
|
Copyright (c) 2023 TailAdmin
|
||||||
|
Licensed under the MIT License.
|
||||||
|
Website: https://tailadmin.com
|
||||||
|
|
||||||
|
## TailAdmin MIT License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 TailAdmin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -66,5 +66,6 @@ export const API = {
|
|||||||
},
|
},
|
||||||
Chatbot:{
|
Chatbot:{
|
||||||
CHAT: `${API_URL_ROOT}/chatbot/chat`,
|
CHAT: `${API_URL_ROOT}/chatbot/chat`,
|
||||||
|
HISTORY: `${API_URL_ROOT}/chatbot/history`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,43 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
webpack(config) {
|
webpack(config, { isServer }) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
use: ["@svgr/webpack"],
|
use: ["@svgr/webpack"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isServer) {
|
||||||
|
// Split heavy third-party vendor libraries into their own dedicated chunks
|
||||||
|
config.optimization.splitChunks.cacheGroups = {
|
||||||
|
...config.optimization.splitChunks.cacheGroups,
|
||||||
|
maplibre: {
|
||||||
|
test: /[\\/]node_modules[\\/]maplibre-gl[\\/]/,
|
||||||
|
name: "maplibre",
|
||||||
|
chunks: "all",
|
||||||
|
priority: 40,
|
||||||
|
},
|
||||||
|
quill: {
|
||||||
|
test: /[\\/]node_modules[\\/](react-quill-new|quill|quill-blot-formatter)[\\/]/,
|
||||||
|
name: "quill",
|
||||||
|
chunks: "all",
|
||||||
|
priority: 35,
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
test: /[\\/]node_modules[\\/](apexcharts|react-apexcharts)[\\/]/,
|
||||||
|
name: "charts",
|
||||||
|
chunks: "all",
|
||||||
|
priority: 30,
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
test: /[\\/]node_modules[\\/]@fullcalendar[\\/]/,
|
||||||
|
name: "calendar",
|
||||||
|
chunks: "all",
|
||||||
|
priority: 25,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "fe_admin_history_web",
|
"name": "ultra_history_map",
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fe_admin_history_web",
|
"name": "ultra_history_map",
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.19",
|
"@fullcalendar/core": "^6.1.19",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M26.5002 14.9998C27.8808 14.9998 29 13.8806 29 12.5C29 11.1194 27.8807 10.0002 26.5001 10.0002C25.1194 10.0002 24 11.1195 24 12.5002V14.9998H26.5002ZM19.5 14.9998C20.8807 14.9998 22 13.8805 22 12.4998V5.50018C22 4.11947 20.8807 3.00018 19.5 3.00018C18.1193 3.00018 17 4.11947 17 5.50018V12.4998C17 13.8805 18.1193 14.9998 19.5 14.9998Z" fill="#2EB67D"/>
|
|
||||||
<path d="M5.49979 17.0002C4.11919 17.0002 3 18.1194 3 19.5C3 20.8806 4.1193 21.9998 5.49989 21.9998C6.8806 21.9998 8 20.8805 8 19.4998V17.0002H5.49979ZM12.5 17.0002C11.1193 17.0002 10 18.1195 10 19.5002V26.4998C10 27.8805 11.1193 28.9998 12.5 28.9998C13.8807 28.9998 15 27.8805 15 26.4998V19.5002C15 18.1195 13.8807 17.0002 12.5 17.0002Z" fill="#E01E5A"/>
|
|
||||||
<path d="M17.0002 26.5002C17.0002 27.8808 18.1194 29 19.5 29C20.8806 29 21.9998 27.8807 21.9998 26.5001C21.9998 25.1194 20.8805 24 19.4998 24L17.0002 24L17.0002 26.5002ZM17.0002 19.5C17.0002 20.8807 18.1195 22 19.5002 22L26.4998 22C27.8805 22 28.9998 20.8807 28.9998 19.5C28.9998 18.1193 27.8805 17 26.4998 17L19.5002 17C18.1195 17 17.0002 18.1193 17.0002 19.5Z" fill="#ECB22E"/>
|
|
||||||
<path d="M14.9998 5.49979C14.9998 4.11919 13.8806 3 12.5 3C11.1194 3 10.0002 4.1193 10.0002 5.49989C10.0002 6.88061 11.1195 8 12.5002 8L14.9998 8L14.9998 5.49979ZM14.9998 12.5C14.9998 11.1193 13.8805 10 12.4998 10L5.50024 10C4.11953 10 3.00024 11.1193 3.00024 12.5C3.00024 13.8807 4.11953 15 5.50024 15L12.4998 15C13.8805 15 14.9998 13.8807 14.9998 12.5Z" fill="#36C5F0"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,10 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="16" cy="16" r="14" fill="url(#paint0_linear_1589_37170)"/>
|
|
||||||
<path d="M21.2137 20.2816L21.8356 16.3301H17.9452V13.767C17.9452 12.6857 18.4877 11.6311 20.2302 11.6311H22V8.26699C22 8.26699 20.3945 8 18.8603 8C15.6548 8 13.5617 9.89294 13.5617 13.3184V16.3301H10V20.2816H13.5617V29.8345C14.2767 29.944 15.0082 30 15.7534 30C16.4986 30 17.2302 29.944 17.9452 29.8345V20.2816H21.2137Z" fill="white"/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_1589_37170" x1="16" y1="2" x2="16" y2="29.917" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#18ACFE"/>
|
|
||||||
<stop offset="1" stop-color="#0163E0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 725 B |
@@ -1,5 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M19.3851 10.9171C20.4654 9.0306 19.8243 6.61829 17.9532 5.5291C16.0821 4.4399 13.6896 5.08628 12.6093 6.97282L4.53087 21.0806C3.45059 22.9671 4.09168 25.3794 5.96277 26.4686C7.83387 27.5578 10.2264 26.9114 11.3067 25.0249L19.3851 10.9171Z" fill="#F8BB2D"/>
|
|
||||||
<path d="M11.8263 23.0546C11.8263 25.2336 10.0743 27 7.91313 27C5.75197 27 4 25.2336 4 23.0546C4 20.8756 5.75197 19.1091 7.91313 19.1091C10.0743 19.1091 11.8263 20.8756 11.8263 23.0546Z" fill="#3BA757"/>
|
|
||||||
<path d="M12.621 10.9171C11.5407 9.0306 12.1818 6.61829 14.0529 5.5291C15.924 4.4399 18.3165 5.08628 19.3968 6.97282L27.4752 21.0806C28.5555 22.9671 27.9144 25.3794 26.0433 26.4686C24.1722 27.5578 21.7797 26.9114 20.6994 25.0249L12.621 10.9171Z" fill="#4689F2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 836 B |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="url(#paint0_radial_1589_37182)"/>
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="url(#paint1_radial_1589_37182)"/>
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="url(#paint2_radial_1589_37182)"/>
|
|
||||||
<path d="M23 10.5C23 11.3284 22.3284 12 21.5 12C20.6716 12 20 11.3284 20 10.5C20 9.67157 20.6716 9 21.5 9C22.3284 9 23 9.67157 23 10.5Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 21C18.7614 21 21 18.7614 21 16C21 13.2386 18.7614 11 16 11C13.2386 11 11 13.2386 11 16C11 18.7614 13.2386 21 16 21ZM16 19C17.6569 19 19 17.6569 19 16C19 14.3431 17.6569 13 16 13C14.3431 13 13 14.3431 13 16C13 17.6569 14.3431 19 16 19Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 15.6C6 12.2397 6 10.5595 6.65396 9.27606C7.2292 8.14708 8.14708 7.2292 9.27606 6.65396C10.5595 6 12.2397 6 15.6 6H16.4C19.7603 6 21.4405 6 22.7239 6.65396C23.8529 7.2292 24.7708 8.14708 25.346 9.27606C26 10.5595 26 12.2397 26 15.6V16.4C26 19.7603 26 21.4405 25.346 22.7239C24.7708 23.8529 23.8529 24.7708 22.7239 25.346C21.4405 26 19.7603 26 16.4 26H15.6C12.2397 26 10.5595 26 9.27606 25.346C8.14708 24.7708 7.2292 23.8529 6.65396 22.7239C6 21.4405 6 19.7603 6 16.4V15.6ZM15.6 8H16.4C18.1132 8 19.2777 8.00156 20.1779 8.0751C21.0548 8.14674 21.5032 8.27659 21.816 8.43597C22.5686 8.81947 23.1805 9.43139 23.564 10.184C23.7234 10.4968 23.8533 10.9452 23.9249 11.8221C23.9984 12.7223 24 13.8868 24 15.6V16.4C24 18.1132 23.9984 19.2777 23.9249 20.1779C23.8533 21.0548 23.7234 21.5032 23.564 21.816C23.1805 22.5686 22.5686 23.1805 21.816 23.564C21.5032 23.7234 21.0548 23.8533 20.1779 23.9249C19.2777 23.9984 18.1132 24 16.4 24H15.6C13.8868 24 12.7223 23.9984 11.8221 23.9249C10.9452 23.8533 10.4968 23.7234 10.184 23.564C9.43139 23.1805 8.81947 22.5686 8.43597 21.816C8.27659 21.5032 8.14674 21.0548 8.0751 20.1779C8.00156 19.2777 8 18.1132 8 16.4V15.6C8 13.8868 8.00156 12.7223 8.0751 11.8221C8.14674 10.9452 8.27659 10.4968 8.43597 10.184C8.81947 9.43139 9.43139 8.81947 10.184 8.43597C10.4968 8.27659 10.9452 8.14674 11.8221 8.0751C12.7223 8.00156 13.8868 8 15.6 8Z" fill="white"/>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="paint0_radial_1589_37182" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 23) rotate(-55.3758) scale(25.5196)">
|
|
||||||
<stop stop-color="#B13589"/>
|
|
||||||
<stop offset="0.79309" stop-color="#C62F94"/>
|
|
||||||
<stop offset="1" stop-color="#8A3AC8"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="paint1_radial_1589_37182" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(11 31) rotate(-65.1363) scale(22.5942)">
|
|
||||||
<stop stop-color="#E0E8B7"/>
|
|
||||||
<stop offset="0.444662" stop-color="#FB8A2E"/>
|
|
||||||
<stop offset="0.71474" stop-color="#E2425C"/>
|
|
||||||
<stop offset="1" stop-color="#E2425C" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="paint2_radial_1589_37182" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(0.500002 3) rotate(-8.1301) scale(38.8909 8.31836)">
|
|
||||||
<stop offset="0.156701" stop-color="#406ADC"/>
|
|
||||||
<stop offset="0.467799" stop-color="#6A45BE"/>
|
|
||||||
<stop offset="1" stop-color="#6A45BE" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,6 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M30.0014 16.3109C30.0014 15.1598 29.9061 14.3198 29.6998 13.4487H16.2871V18.6442H24.1601C24.0014 19.9354 23.1442 21.8798 21.2394 23.1864L21.2127 23.3604L25.4536 26.58L25.7474 26.6087C28.4458 24.1665 30.0014 20.5731 30.0014 16.3109Z" fill="#4285F4"/>
|
|
||||||
<path d="M16.2863 30C20.1434 30 23.3814 28.7555 25.7466 26.6089L21.2386 23.1865C20.0323 24.011 18.4132 24.5866 16.2863 24.5866C12.5086 24.5866 9.30225 22.1444 8.15929 18.7688L7.99176 18.7827L3.58208 22.1272L3.52441 22.2843C5.87359 26.8577 10.699 30 16.2863 30Z" fill="#34A853"/>
|
|
||||||
<path d="M8.16013 18.7688C7.85855 17.8977 7.68401 16.9643 7.68401 15.9999C7.68401 15.0354 7.85855 14.1021 8.14426 13.231L8.13627 13.0455L3.67132 9.64734L3.52524 9.71544C2.55703 11.6132 2.00146 13.7444 2.00146 15.9999C2.00146 18.2555 2.55703 20.3865 3.52524 22.2843L8.16013 18.7688Z" fill="#FBBC05"/>
|
|
||||||
<path d="M16.2864 7.4133C18.9689 7.4133 20.7784 8.54885 21.8102 9.4978L25.8419 5.64C23.3658 3.38445 20.1435 2 16.2864 2C10.699 2 5.8736 5.1422 3.52441 9.71549L8.14345 13.2311C9.30229 9.85555 12.5086 7.4133 16.2864 7.4133Z" fill="#EB4335"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M2.24451 9.94111C2.37304 7.96233 3.96395 6.41157 5.94447 6.31345C8.81239 6.17136 12.9115 6 16 6C19.0885 6 23.1876 6.17136 26.0555 6.31345C28.0361 6.41157 29.627 7.96233 29.7555 9.94111C29.8786 11.8369 30 14.1697 30 16C30 17.8303 29.8786 20.1631 29.7555 22.0589C29.627 24.0377 28.0361 25.5884 26.0555 25.6866C23.1876 25.8286 19.0885 26 16 26C12.9115 26 8.81239 25.8286 5.94447 25.6866C3.96395 25.5884 2.37304 24.0377 2.24451 22.0589C2.12136 20.1631 2 17.8303 2 16C2 14.1697 2.12136 11.8369 2.24451 9.94111Z" fill="#FC0D1B"/>
|
|
||||||
<path d="M13 12V20L21 16L13 12Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 684 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M37.5 20C37.5 29.66 29.6687 37.5 20 37.5C10.3312 37.5 2.5 29.66 2.5 20C2.5 10.3312 10.3312 2.49999 20 2.49999C29.6687 2.49999 37.5 10.3312 37.5 20Z" fill="#283544"/>
|
|
||||||
<path d="M28.2026 15.5717C28.1071 15.6274 25.8338 16.8031 25.8338 19.4098C25.941 22.3826 28.7026 23.4252 28.75 23.4252C28.7026 23.4809 28.3331 24.8454 27.2383 26.2757C26.3696 27.5078 25.4053 28.75 23.941 28.75C22.5481 28.75 22.0481 27.9288 20.441 27.9288C18.715 27.9288 18.2267 28.75 16.9052 28.75C15.441 28.75 14.4052 27.4412 13.4891 26.2207C12.2989 24.6232 11.2872 22.1164 11.2515 19.7093C11.2274 18.4338 11.4899 17.18 12.156 16.1151C13.0962 14.6283 14.7748 13.619 16.6079 13.5858C18.0124 13.5416 19.2624 14.4843 20.1195 14.4843C20.941 14.4843 22.4767 13.5858 24.2143 13.5858C24.9643 13.5865 26.9643 13.797 28.2026 15.5717ZM20.0008 13.3311C19.7508 12.1663 20.441 11.0015 21.0838 10.2585C21.9053 9.3599 23.2026 8.75 24.3214 8.75C24.3928 9.91481 23.9402 11.0572 23.1312 11.8892C22.4053 12.7878 21.1553 13.4642 20.0008 13.3311Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +0,0 @@
|
|||||||
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="0.5" width="40" height="40" rx="20" fill="#1B4BF1"/>
|
|
||||||
<path opacity="0.5" d="M28.9266 15.7265C29.1963 13.9679 28.9266 12.7956 27.9826 11.7134C26.9487 10.496 25.0607 10 22.6333 10H15.6657C15.1713 10 14.7667 10.3607 14.6768 10.8567L11.7549 29.3437C11.7099 29.7044 11.9797 30.02 12.3393 30.02H16.6547L16.34 31.9138C16.2951 32.2295 16.5198 32.5 16.8794 32.5H20.5205C20.9701 32.5 21.3297 32.1844 21.3746 31.7786L22.1388 26.999C22.1838 26.5932 22.5883 26.2776 22.9929 26.2776H23.5323C27.0386 26.2776 29.8256 24.8347 30.6348 20.6864C30.9494 18.9729 30.8146 17.485 29.9155 16.493C29.6458 16.1774 29.3311 15.9519 28.9266 15.7265" fill="white"/>
|
|
||||||
<path d="M28.9266 15.7265C29.1963 13.9679 28.9266 12.7956 27.9826 11.7134C26.9487 10.496 25.0607 10 22.6333 10H15.6657C15.1713 10 14.7667 10.3607 14.6768 10.8567L11.7549 29.3437C11.7099 29.7044 11.9797 30.02 12.3393 30.02H16.6547L17.6886 23.3467C17.7785 22.8507 18.183 22.49 18.6775 22.49H20.7453C24.791 22.49 27.9376 20.8667 28.8367 16.0872C28.8816 15.997 28.8816 15.8617 28.9266 15.7265Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.1209 16.096C30.1192 16.105 30.1161 16.121 30.1115 16.1467C30.1057 16.1785 30.0932 16.2474 30.0743 16.3214C30.0669 16.3504 30.0572 16.3854 30.0445 16.425C29.5373 19.0088 28.3926 20.8874 26.6974 22.0981C25.0064 23.3058 22.9195 23.74 20.7452 23.74H18.8924L17.7258 31.27H12.3392C11.1964 31.27 10.3804 30.2642 10.5144 29.1891L10.5169 29.1688L13.4442 10.6476L13.4467 10.6338C13.6357 9.59143 14.519 8.75 15.6656 8.75H22.6332C25.1361 8.75 27.5194 9.24302 28.9298 10.8979C29.516 11.5715 29.9235 12.3133 30.1267 13.1814C30.3266 14.0353 30.3118 14.9394 30.162 15.916L30.146 16.0204L30.1209 16.096ZM27.9825 11.7134C26.9486 10.496 25.0606 10 22.6332 10H15.6656C15.1712 10 14.7666 10.3607 14.6767 10.8567L11.7548 29.3437C11.7099 29.7044 11.9796 30.02 12.3392 30.02H16.6546L17.6885 23.3467C17.7784 22.8507 18.1829 22.49 18.6774 22.49H20.7452C24.7909 22.49 27.9375 20.8667 28.8366 16.0872C28.8609 16.0384 28.8721 15.9763 28.8843 15.9082C28.8947 15.8505 28.9059 15.7885 28.9265 15.7265C29.1962 13.9679 28.9265 12.7956 27.9825 11.7134Z" fill="#1B4BF1"/>
|
|
||||||
<path d="M18.9023 15.7715C18.9472 15.4559 19.3518 15.0501 19.7564 15.0501H25.2405C25.8698 15.0501 26.4992 15.0952 27.0386 15.1854C27.5331 15.2755 28.4321 15.501 28.8816 15.7715C29.1513 14.013 28.8816 12.8407 27.9376 11.7585C26.9487 10.496 25.0607 10 22.6333 10H15.6657C15.1713 10 14.7667 10.3607 14.6768 10.8567L11.7549 29.3437C11.7099 29.7044 11.9797 30.02 12.3393 30.02H16.6547L18.9023 15.7715V15.7715Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="20" cy="20" r="20" fill="#E31937"/>
|
|
||||||
<path d="M20.0005 31.4285L22.9444 14.8726C25.7502 14.8726 26.6353 15.1803 26.7632 16.4362C26.7632 16.4362 28.6456 15.7343 29.5948 14.3089C25.8901 12.5923 22.1678 12.5148 22.1678 12.5148L19.9957 15.1604L20.0006 15.16L17.8285 12.5144C17.8285 12.5144 14.106 12.5919 10.4019 14.3086C11.3504 15.734 13.2334 16.4358 13.2334 16.4358C13.362 15.1799 14.2459 14.8722 17.033 14.8702L20.0005 31.4285Z" fill="white"/>
|
|
||||||
<path d="M19.9996 11.7508C22.9943 11.7279 26.422 12.2141 29.9311 13.7434C30.4001 12.8993 30.5207 12.5262 30.5207 12.5262C26.6847 11.0086 23.0925 10.4893 19.9992 10.4762C16.906 10.4893 13.3139 11.0087 9.47852 12.5262C9.47852 12.5262 9.64962 12.9858 10.0677 13.7434C13.576 12.2141 17.0045 11.7279 19.9993 11.7508H19.9996Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 892 B |
@@ -1,6 +0,0 @@
|
|||||||
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20.5 2.5C10.8477 2.5 3 10.3477 3 20C3 29.6523 10.8477 37.5 20.5 37.5C30.1523 37.5 38 29.6523 38 20C38 10.3477 30.1523 2.5 20.5 2.5Z" fill="#F9981B"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5124 22.5475C26.461 22.4738 26.4093 22.4016 26.3578 22.3298C25.9392 21.7463 25.5402 21.1902 25.5402 20.0754V15.9195C25.5402 15.775 25.541 15.6315 25.5418 15.4891C25.551 13.8957 25.5593 12.4417 24.4101 11.3294C23.4246 10.3467 21.7876 10 20.5357 10C18.0872 10 15.3558 10.947 14.783 14.0851C14.7223 14.4184 14.9558 14.5929 15.1675 14.642L17.6607 14.9218C17.8941 14.9095 18.0627 14.6715 18.1079 14.4318C18.3217 13.3505 19.195 12.831 20.1762 12.831C20.7054 12.831 21.3063 13.0316 21.6196 13.5238C21.9425 14.0146 21.9374 14.6699 21.9328 15.2558C21.9323 15.3241 21.9317 15.3914 21.9317 15.4574V15.8058C21.7004 15.8326 21.458 15.858 21.2083 15.8842C19.8486 16.0267 18.2732 16.1919 17.0932 16.7283C15.4823 17.4517 14.3511 18.9238 14.3511 21.0876C14.3511 23.8606 16.0344 25.2469 18.2016 25.2469C20.0305 25.2469 21.0314 24.7993 22.4423 23.3077C22.5128 23.4136 22.5762 23.5113 22.6358 23.6033C22.9724 24.1222 23.1921 24.4609 23.9171 25.0864C24.1085 25.1923 24.3618 25.1883 24.5314 25.0289C25.0451 24.5552 25.9779 23.7146 26.5039 23.2576C26.714 23.0786 26.6767 22.7894 26.5124 22.5475ZM21.4505 21.3491C21.0421 22.1015 20.3916 22.5619 19.6689 22.5619C18.6834 22.5619 18.1047 21.7816 18.1047 20.6272C18.1047 18.3547 20.0714 17.9423 21.9312 17.9423C21.9312 18.0782 21.9325 18.2149 21.9337 18.352C21.9428 19.3807 21.9521 20.4299 21.4505 21.3491Z" fill="white"/>
|
|
||||||
<path d="M29.5873 27.0823C27.1246 28.9867 23.5531 30 20.4773 30C16.1681 30 12.2866 28.3307 9.34948 25.5515C9.11909 25.333 9.32473 25.0346 9.60151 25.2042C12.7708 27.1371 16.6894 28.3006 20.7367 28.3006C23.4672 28.3006 26.4682 27.7073 29.2295 26.479C29.646 26.2944 29.9951 26.7674 29.5873 27.0823Z" fill="white"/>
|
|
||||||
<path d="M27.7363 25.7559C28.5293 25.6555 30.2974 25.4334 30.6122 25.8556C30.9273 26.2775 30.2654 28.0124 29.967 28.7943L29.9649 28.8C29.8751 29.0358 30.0681 29.1299 30.2715 28.9512C31.5931 27.7933 31.9346 25.366 31.6638 25.0143C31.3951 24.6674 29.0847 24.367 27.6747 25.4049C27.458 25.5659 27.4951 25.7852 27.7363 25.7559Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="20" cy="20" r="17.5" fill="#1ED760"/>
|
|
||||||
<path d="M27.9554 27.0287C27.6564 27.5031 27.0186 27.6359 26.5203 27.3513C22.5937 25.0742 17.6705 24.5618 11.8503 25.8142C11.2922 25.9281 10.7341 25.6055 10.6145 25.0742C10.4949 24.5428 10.8338 24.0115 11.3919 23.8976C17.7502 22.5124 23.2116 23.1007 27.5966 25.6624C28.095 25.9471 28.2544 26.5543 27.9554 27.0287ZM29.9885 22.7022C29.6098 23.2904 28.8125 23.4612 28.1946 23.1196C23.7099 20.482 16.8732 19.7229 11.5712 21.26C10.8736 21.4497 10.1561 21.0892 9.95674 20.444C9.75742 19.7798 10.1361 19.0967 10.8338 18.9069C16.8931 17.1611 24.4274 17.9961 29.5899 21.0133C30.1679 21.3549 30.3672 22.1139 29.9885 22.7022ZM30.1679 18.1859C24.7862 15.1497 15.9164 14.865 10.774 16.3452C9.95674 16.5919 9.07973 16.1554 8.82061 15.3584C8.5615 14.5804 9.03987 13.7455 9.85708 13.4988C15.757 11.7909 25.5636 12.1325 31.7425 15.6241C32.48 16.0416 32.7192 16.9524 32.2807 17.6545C31.8621 18.3756 30.9054 18.6223 30.1679 18.1859Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20 2.5C10.3477 2.5 2.5 10.3477 2.5 20C2.5 29.6523 10.3477 37.5 20 37.5C29.6523 37.5 37.5 29.6523 37.5 20C37.5 10.3477 29.6523 2.5 20 2.5Z" fill="#FF5A5F"/>
|
|
||||||
<path d="M20.0002 24.6084C18.8528 23.1652 18.1779 21.9001 17.953 20.8178C17.728 19.9383 17.818 19.2393 18.2004 18.7206C18.6054 18.1118 19.2128 17.8186 20.0002 17.8186C20.7875 17.8186 21.3949 18.1118 21.7999 18.7206C22.1823 19.2393 22.2723 19.9383 22.0473 20.8178C21.7999 21.9227 21.125 23.1855 20.0002 24.6084ZM26.2812 27.9458C24.369 28.7801 22.477 27.4497 20.8573 25.6457C23.5366 22.2835 24.0315 19.6677 22.882 17.9742C22.2071 17.0046 21.2397 16.531 20.0002 16.531C17.503 16.531 16.1285 18.6507 16.6684 21.1109C16.9834 22.4413 17.8158 23.9544 19.143 25.6457C18.1475 26.7515 16.8414 28.0059 15.2736 28.1487C13.0015 28.487 11.222 26.2771 12.0319 23.9973L17.7933 12.0413C18.285 11.1425 18.8919 10.3726 19.9979 10.3726C20.8078 10.3726 21.4377 10.8462 21.7076 11.2295L27.9639 23.9973C28.5769 25.54 27.7996 27.2886 26.2812 27.9458ZM29.1832 23.5463L23.8268 12.3796C22.8145 10.305 22.0946 9.0625 20.0002 9.0625C17.9305 9.0625 17.0509 10.5057 16.151 12.3796L10.8171 23.5463C9.66976 26.7055 12.0319 29.4792 14.8912 29.4792C15.0712 29.4792 15.2499 29.4566 15.4311 29.4566C16.9159 29.2762 18.4479 28.3291 20.0002 26.6356C21.5524 28.3269 23.0844 29.2762 24.5692 29.4566C24.7505 29.4566 24.9291 29.4792 25.1091 29.4792C27.9684 29.4814 30.3306 26.7055 29.1832 23.5463Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20 2.5C10.3477 2.5 2.5 10.3477 2.5 20C2.5 29.6523 10.3477 37.5 20 37.5C29.6523 37.5 37.5 29.6523 37.5 20C37.5 10.3477 29.6523 2.5 20 2.5Z" fill="#87E64B"/>
|
|
||||||
<path d="M20.4865 30C20.9801 30 21.3803 29.5998 21.3803 29.1062C21.3803 28.6126 20.9801 28.2125 20.4865 28.2125C19.9929 28.2125 19.5928 28.6126 19.5928 29.1062C19.5928 29.5998 19.9929 30 20.4865 30Z" fill="white"/>
|
|
||||||
<path d="M25.6233 23.0134L20.5833 23.5539C20.4895 23.5633 20.4427 23.4477 20.5177 23.3883L25.4483 19.5482C25.767 19.2857 25.9732 18.8795 25.8857 18.4421C25.7982 17.7734 25.2452 17.336 24.5453 17.4235L19.1866 18.2077C19.0928 18.2202 19.0428 18.1015 19.1178 18.0421L24.4297 13.9864C25.4764 13.1709 25.5639 11.5711 24.6046 10.6399C23.7329 9.76817 22.3331 9.79629 21.4613 10.6681L12.903 19.3763C12.5843 19.7263 12.4374 20.1919 12.5249 20.6855C12.6718 21.4729 13.4561 21.9947 14.2435 21.851L18.8585 20.9105C18.9585 20.8886 19.0116 21.023 18.9272 21.0792L13.8091 24.3569C13.1686 24.7631 12.878 25.4912 13.0811 26.2192C13.2842 27.1785 14.2466 27.7315 15.1777 27.5003L22.8299 25.6162C22.9174 25.5943 22.9798 25.6943 22.9236 25.763L21.73 27.2378C21.4113 27.644 21.9331 28.1971 22.3705 27.8784L26.3013 24.6475C27.0012 24.0664 26.5356 22.929 25.6326 23.0165L25.6233 23.0134Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +0,0 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="20" cy="20" r="17.5" fill="#FF8C00"/>
|
|
||||||
<path d="M27.4363 25.1256C27.408 24.8784 27.2659 24.3115 26.8965 24.1659H26.897C26.712 24.0931 26.5558 24.151 26.4852 24.2821C26.3713 24.486 26.4565 24.8784 26.6697 25.2278C26.8824 25.5768 27.0814 25.7513 27.2235 25.7513C27.3656 25.7513 27.4932 25.5768 27.4363 25.1256Z" fill="white"/>
|
|
||||||
<path d="M25.4485 26.1147C25.2213 25.9113 24.9657 25.8096 24.7384 25.8096C24.5398 25.8096 24.3836 25.8819 24.2839 26.013C24.0288 26.3326 24.1422 26.9148 24.5398 27.2638C24.7102 27.4239 24.9657 27.5111 25.2071 27.5111C25.4485 27.5111 25.6613 27.4094 25.7892 27.2494C26.0165 26.9293 25.8885 26.4931 25.4485 26.1147Z" fill="white"/>
|
|
||||||
<path d="M10 20.0061C10 25.5328 14.3728 30.0125 19.7687 30.0128C21.203 30.0128 22.6227 29.7365 24.7102 29.7216C26.5704 29.7216 28.615 30.3907 30.8583 32.4272C31.0856 32.6306 31.3835 32.3688 31.1849 32.1216C28.984 29.2565 26.9393 28.7185 24.8947 28.2529C22.3959 27.6856 21.1178 26.2603 20.2233 24.6749C20.0529 24.3549 19.9677 24.4132 19.9532 24.8205C19.9371 25.4198 19.98 26.0192 20.0812 26.6097H19.7833C16.2191 26.6097 13.3223 23.6424 13.3223 19.9917C13.3223 16.3409 16.2191 13.3737 19.7828 13.3737C23.347 13.3737 26.2438 16.3409 26.2438 19.9917C26.2438 20.2534 26.2292 20.5152 26.201 20.7624C25.7182 20.6752 24.7954 20.6607 24.1422 20.719C23.9008 20.748 23.9291 20.8641 24.114 20.8935C26.2438 21.3004 27.7063 22.6534 28.047 25.1112C28.0612 25.1695 28.1322 25.1839 28.1609 25.1405C29.0268 23.6424 29.5378 21.8822 29.5378 20.0061C29.5378 14.4798 25.1643 10 19.7687 10C14.3735 10 10 14.4794 10 20.0061Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30Z" fill="#2BDE73"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.357 13.4527L17.3808 9.20142C17.9525 8.40047 18.6904 8 19.5953 8C20.3334 8 20.972 8.25407 21.512 8.76237C22.0516 9.27068 22.3213 9.8791 22.3213 10.5876C22.3213 11.1114 22.1788 11.5736 21.8929 11.9741L19.1665 15.821L22.5 19.9222C22.8334 20.3303 23 20.808 23 21.3547C23 22.0788 22.7384 22.7004 22.2144 23.2203C21.6906 23.7403 21.0553 24 20.3096 24C19.492 24 18.8691 23.7423 18.4404 23.2261L14.357 18.2817V21.0082C14.357 21.7862 14.2182 22.3906 13.9403 22.8219C13.4324 23.6072 12.6943 24 11.7262 24C10.8452 24 10.1624 23.7112 9.67848 23.1337C9.22604 22.6022 9 21.8973 9 21.0198V10.9111C9 10.0793 9.22983 9.39412 9.69041 8.85495C10.1745 8.2851 10.841 8 11.6903 8C12.4999 8 13.1744 8.2851 13.7141 8.85495C14.0157 9.17056 14.2061 9.4902 14.2855 9.8137C14.3332 10.0141 14.357 10.3874 14.357 10.9343V13.4527Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 704 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 849 KiB |
@@ -1,15 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1788_4304)">
|
|
||||||
<path d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M15.3037 16H31.9993C31.9993 14.5559 31.8068 13.1569 31.4481 11.826H15.3037V16Z" fill="#D80027"/>
|
|
||||||
<path d="M15.3037 7.65135H29.651C28.6715 6.0531 27.4192 4.64042 25.9591 3.47742H15.3037V7.65135Z" fill="#D80027"/>
|
|
||||||
<path d="M15.9995 32C19.7651 32 23.2262 30.6985 25.9593 28.5217H6.03979C8.77292 30.6985 12.234 32 15.9995 32Z" fill="#D80027"/>
|
|
||||||
<path d="M2.34797 24.3465H29.6512C30.4375 23.0635 31.0473 21.661 31.4484 20.1726H0.550781C0.951844 21.661 1.56166 23.0635 2.34797 24.3465Z" fill="#D80027"/>
|
|
||||||
<path d="M7.4115 2.49863H8.86956L7.51331 3.48394L8.03137 5.07825L6.67519 4.09294L5.319 5.07825L5.7665 3.70094C4.57237 4.69562 3.52575 5.861 2.66325 7.1595H3.13044L2.26712 7.78669C2.13262 8.01106 2.00362 8.239 1.88 8.47031L2.29225 9.73913L1.52313 9.18031C1.33194 9.58537 1.15706 9.99956 0.999875 10.4224L1.45406 11.8204H3.13044L1.77419 12.8057L2.29225 14.4L0.936063 13.4147L0.123687 14.0049C0.042375 14.6586 0 15.3243 0 16H16C16 7.1635 16 6.12175 16 0C12.8393 0 9.89281 0.916875 7.4115 2.49863ZM8.03137 14.4L6.67519 13.4147L5.319 14.4L5.83706 12.8057L4.48081 11.8204H6.15719L6.67519 10.2261L7.19319 11.8204H8.86956L7.51331 12.8057L8.03137 14.4ZM7.51331 8.14481L8.03137 9.73913L6.67519 8.75381L5.319 9.73913L5.83706 8.14481L4.48081 7.1595H6.15719L6.67519 5.56519L7.19319 7.1595H8.86956L7.51331 8.14481ZM13.7705 14.4L12.4143 13.4147L11.0581 14.4L11.5762 12.8057L10.2199 11.8204H11.8963L12.4143 10.2261L12.9323 11.8204H14.6087L13.2524 12.8057L13.7705 14.4ZM13.2524 8.14481L13.7705 9.73913L12.4143 8.75381L11.0581 9.73913L11.5762 8.14481L10.2199 7.1595H11.8963L12.4143 5.56519L12.9323 7.1595H14.6087L13.2524 8.14481ZM13.2524 3.48394L13.7705 5.07825L12.4143 4.09294L11.0581 5.07825L11.5762 3.48394L10.2199 2.49863H11.8963L12.4143 0.904312L12.9323 2.49863H14.6087L13.2524 3.48394Z" fill="#0052B4"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1788_4304">
|
|
||||||
<rect width="32" height="32" rx="16" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1788_4322)">
|
|
||||||
<path d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M32 15.9999C32 9.12049 27.658 3.2558 21.5652 0.995117V31.0048C27.658 28.7441 32 22.8794 32 15.9999Z" fill="#D80027"/>
|
|
||||||
<path d="M0.000488281 16.001C0.000488281 22.8805 4.34255 28.7452 10.4353 31.0058V0.996216C4.34255 3.2569 0.000488281 9.12159 0.000488281 16.001Z" fill="#0052B4"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1788_4322">
|
|
||||||
<rect width="32" height="32" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 680 B |
@@ -1,17 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35166)">
|
|
||||||
<path d="M10.875 20.5C16.3978 20.5 20.875 16.0228 20.875 10.5C20.875 4.97715 16.3978 0.5 10.875 0.5C5.35215 0.5 0.875 4.97715 0.875 10.5C0.875 16.0228 5.35215 20.5 10.875 20.5Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M0.875 10.5C0.875 4.97719 5.35219 0.5 10.875 0.5C16.3978 0.5 20.875 4.97719 20.875 10.5" fill="#D80027"/>
|
|
||||||
<path d="M6.96163 5.71751C6.96163 4.26056 7.98558 3.04345 9.35292 2.74481C9.14276 2.69896 8.92475 2.67407 8.70073 2.67407C7.01983 2.67407 5.65726 4.03665 5.65726 5.71755C5.65726 7.39845 7.01983 8.76103 8.70073 8.76103C8.92468 8.76103 9.14272 8.73614 9.35292 8.69025C7.98558 8.39161 6.96163 7.1745 6.96163 5.71751Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M10.8747 2.89172L11.0905 3.55598H11.789L11.2239 3.96657L11.4398 4.63083L10.8747 4.22032L10.3096 4.63083L10.5255 3.96657L9.96039 3.55598H10.6588L10.8747 2.89172Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M9.18043 4.19556L9.39625 4.85985H10.0947L9.52964 5.2704L9.7455 5.93466L9.18043 5.52415L8.61527 5.93466L8.83117 5.2704L8.26605 4.85985H8.96453L9.18043 4.19556Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M12.5691 4.19556L12.785 4.85985H13.4835L12.9184 5.2704L13.1343 5.93466L12.5691 5.52415L12.0041 5.93466L12.2199 5.2704L11.6548 4.85985H12.3533L12.5691 4.19556Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M11.9171 6.15283L12.133 6.81713H12.8314L12.2663 7.22768L12.4822 7.89193L11.9171 7.48143L11.352 7.89193L11.5679 7.22768L11.0028 6.81713H11.7012L11.9171 6.15283Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M9.83243 6.15283L10.0482 6.81713H10.7468L10.1816 7.22768L10.3975 7.89193L9.83243 7.48143L9.26731 7.89193L9.48317 7.22768L8.91809 6.81713H9.61657L9.83243 6.15283Z" fill="#F0F0F0"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35166">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,23 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35168)">
|
|
||||||
<path d="M10.875 20.5C16.3978 20.5 20.875 16.0228 20.875 10.5C20.875 4.97715 16.3978 0.5 10.875 0.5C5.35215 0.5 0.875 4.97715 0.875 10.5C0.875 16.0228 5.35215 20.5 10.875 20.5Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M2.94224 4.41089C2.15673 5.43288 1.56443 6.61081 1.21954 7.89046H6.42181L2.94224 4.41089Z" fill="#0052B4"/>
|
|
||||||
<path d="M20.5309 7.89054C20.186 6.61093 19.5936 5.433 18.8082 4.41101L15.3287 7.89054H20.5309Z" fill="#0052B4"/>
|
|
||||||
<path d="M1.21954 13.1085C1.56447 14.3881 2.15677 15.5661 2.94224 16.588L6.42169 13.1085H1.21954Z" fill="#0052B4"/>
|
|
||||||
<path d="M16.9629 2.56649C15.9409 1.78098 14.763 1.18867 13.4834 0.84375V6.04598L16.9629 2.56649Z" fill="#0052B4"/>
|
|
||||||
<path d="M4.78662 18.4314C5.80861 19.2169 6.98655 19.8092 8.26616 20.1541V14.9519L4.78662 18.4314Z" fill="#0052B4"/>
|
|
||||||
<path d="M8.26611 0.84375C6.9865 1.18867 5.80857 1.78098 4.78662 2.56644L8.26611 6.04593V0.84375Z" fill="#0052B4"/>
|
|
||||||
<path d="M13.4834 20.1541C14.763 19.8092 15.9409 19.2169 16.9629 18.4314L13.4834 14.9519V20.1541Z" fill="#0052B4"/>
|
|
||||||
<path d="M15.3287 13.1085L18.8082 16.588C19.5936 15.5661 20.186 14.3881 20.5309 13.1085H15.3287Z" fill="#0052B4"/>
|
|
||||||
<path d="M20.7904 9.19566H12.1794H12.1794V0.584648C11.7524 0.529063 11.3171 0.5 10.875 0.5C10.4329 0.5 9.99762 0.529063 9.57066 0.584648V9.19559V9.19563H0.959648C0.904063 9.62262 0.875 10.0579 0.875 10.5C0.875 10.9421 0.904063 11.3774 0.959648 11.8043H9.57059H9.57063V20.4154C9.99762 20.4709 10.4329 20.5 10.875 20.5C11.3171 20.5 11.7524 20.471 12.1793 20.4154V11.8044V11.8044H20.7904C20.8459 11.3774 20.875 10.9421 20.875 10.5C20.875 10.0579 20.8459 9.62262 20.7904 9.19566Z" fill="#D80027"/>
|
|
||||||
<path d="M13.4837 13.1094L17.946 17.5718C18.1513 17.3666 18.3471 17.1521 18.5339 16.9298L14.7135 13.1094H13.4837V13.1094Z" fill="#D80027"/>
|
|
||||||
<path d="M8.26628 13.1094H8.2662L3.80389 17.5717C4.00905 17.7769 4.22354 17.9727 4.44589 18.1595L8.26628 14.339V13.1094Z" fill="#D80027"/>
|
|
||||||
<path d="M8.26616 7.89093V7.89085L3.80382 3.42847C3.59858 3.63362 3.4028 3.84812 3.216 4.07046L7.03643 7.89089H8.26616V7.89093Z" fill="#D80027"/>
|
|
||||||
<path d="M13.4837 7.89187L17.9461 3.42945C17.7409 3.22421 17.5264 3.02843 17.3041 2.84167L13.4837 6.6621V7.89187V7.89187Z" fill="#D80027"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35168">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35170)">
|
|
||||||
<path d="M10.875 20.5C16.3978 20.5 20.875 16.0228 20.875 10.5C20.875 4.97715 16.3978 0.5 10.875 0.5C5.35215 0.5 0.875 4.97715 0.875 10.5C0.875 16.0228 5.35215 20.5 10.875 20.5Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M10.875 20.4993C15.1746 20.4993 18.84 17.7856 20.253 13.9775H1.49695C2.90988 17.7856 6.57531 20.4993 10.875 20.4993Z" fill="black"/>
|
|
||||||
<path d="M10.875 0.50061C6.57531 0.50061 2.90988 3.21436 1.49695 7.02237H20.253C18.84 3.21436 15.1746 0.50061 10.875 0.50061Z" fill="#D80027"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35170">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 758 B |
@@ -1,11 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35172)">
|
|
||||||
<path d="M10.875 20.5C16.3978 20.5 20.875 16.0228 20.875 10.5C20.875 4.97715 16.3978 0.5 10.875 0.5C5.35215 0.5 0.875 4.97715 0.875 10.5C0.875 16.0228 5.35215 20.5 10.875 20.5Z" fill="#F0F0F0"/>
|
|
||||||
<path d="M20.7905 9.19564H8.70125H8.70122V0.737671C7.77708 0.942593 6.90094 1.27474 6.0925 1.71587V9.19556V9.1956H0.959771C0.904106 9.62259 0.875122 10.0579 0.875122 10.5C0.875122 10.942 0.904106 11.3774 0.959771 11.8043H6.09247H6.0925V19.284C6.90094 19.7251 7.77708 20.0574 8.70122 20.2622V11.8044V11.8044H20.7905C20.8461 11.3774 20.8751 10.942 20.8751 10.5C20.8751 10.0579 20.8461 9.62259 20.7905 9.19564Z" fill="#0052B4"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35172">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 898 B |
@@ -1,12 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35174)">
|
|
||||||
<path d="M14.353 1.12211C13.2697 0.720161 12.0979 0.500122 10.8747 0.500122C9.65153 0.500122 8.47981 0.720161 7.39649 1.12211L6.52692 10.5001L7.39649 19.8781C8.47981 20.2801 9.65153 20.5001 10.8747 20.5001C12.0979 20.5001 13.2697 20.2801 14.353 19.8781L15.2225 10.5001L14.353 1.12211Z" fill="#FFDA44"/>
|
|
||||||
<path d="M20.8749 10.4994C20.8749 6.19982 18.1612 2.53435 14.3531 1.12146V19.8775C18.1612 18.4645 20.8749 14.7991 20.8749 10.4994Z" fill="#D80027"/>
|
|
||||||
<path d="M0.874664 10.5C0.874664 14.7997 3.58841 18.4651 7.39642 19.8781V1.12207C3.58841 2.53496 0.874664 6.20043 0.874664 10.5Z" fill="black"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35174">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 874 B |
@@ -1,11 +0,0 @@
|
|||||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_1589_35176)">
|
|
||||||
<path d="M10.875 20.5C16.3978 20.5 20.875 16.0228 20.875 10.5C20.875 4.97715 16.3978 0.5 10.875 0.5C5.35215 0.5 0.875 4.97715 0.875 10.5C0.875 16.0228 5.35215 20.5 10.875 20.5Z" fill="#496E2D"/>
|
|
||||||
<path d="M8.70105 14.8479C11.1023 14.8479 13.0489 12.9013 13.0489 10.5C13.0489 8.09881 11.1023 6.15222 8.70105 6.15222C6.29982 6.15222 4.35324 8.09881 4.35324 10.5C4.35324 12.9013 6.29982 14.8479 8.70105 14.8479Z" fill="#D80027"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1589_35176">
|
|
||||||
<rect x="0.875" y="0.5" width="20" height="20" rx="10" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5.61127 24.8528C5.93259 19.9058 9.90987 16.0289 14.8612 15.7836C22.031 15.4284 32.2787 15 40 15C47.7213 15 57.969 15.4284 65.1388 15.7836C70.0901 16.0289 74.0674 19.9058 74.3887 24.8528C74.6966 29.5923 75 35.4241 75 40C75 44.5759 74.6966 50.4077 74.3887 55.1472C74.0674 60.0942 70.0901 63.9711 65.1388 64.2164C57.969 64.5716 47.7213 65 40 65C32.2787 65 22.031 64.5716 14.8612 64.2164C9.90987 63.9711 5.93259 60.0942 5.61127 55.1472C5.30341 50.4077 5 44.5759 5 40C5 35.4241 5.30341 29.5923 5.61127 24.8528Z" fill="#FC0D1B"/>
|
|
||||||
<path d="M32.5 30V50L52.5 40L32.5 30Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 691 B |
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://vi.wikipedia.org/w/api.php"
|
||||||
|
DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parents[1] / "tmp" / "wiki"
|
||||||
|
USER_AGENT = "UltimateHistoryMapWikiImporter/1.0"
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"p",
|
||||||
|
"blockquote",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
"b",
|
||||||
|
"strong",
|
||||||
|
"i",
|
||||||
|
"em",
|
||||||
|
"code",
|
||||||
|
"pre",
|
||||||
|
"a",
|
||||||
|
"br",
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_TAGS = {
|
||||||
|
"audio",
|
||||||
|
"canvas",
|
||||||
|
"figure",
|
||||||
|
"form",
|
||||||
|
"iframe",
|
||||||
|
"img",
|
||||||
|
"input",
|
||||||
|
"map",
|
||||||
|
"math",
|
||||||
|
"meta",
|
||||||
|
"noscript",
|
||||||
|
"picture",
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"svg",
|
||||||
|
"table",
|
||||||
|
"video",
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_CLASS_PARTS = (
|
||||||
|
"ambox",
|
||||||
|
"authority-control",
|
||||||
|
"catlinks",
|
||||||
|
"error",
|
||||||
|
"hatnote",
|
||||||
|
"metadata",
|
||||||
|
"mw-editsection",
|
||||||
|
"mw-empty-elt",
|
||||||
|
"navbox",
|
||||||
|
"navigation-not-searchable",
|
||||||
|
"noprint",
|
||||||
|
"reference",
|
||||||
|
"reflist",
|
||||||
|
"shortdescription",
|
||||||
|
"sidebar",
|
||||||
|
"toc",
|
||||||
|
"vertical-navbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
VOID_TAGS = {"br"}
|
||||||
|
|
||||||
|
|
||||||
|
class WikiHtmlSanitizer(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=False)
|
||||||
|
self.parts: list[str] = []
|
||||||
|
self.open_tags: list[str] = []
|
||||||
|
self.skip_depth = 0
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if self.skip_depth:
|
||||||
|
self.skip_depth += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
attr_map = {name.lower(): value or "" for name, value in attrs}
|
||||||
|
if tag in SKIP_TAGS or self._has_skipped_class(attr_map.get("class", "")):
|
||||||
|
self.skip_depth = 1
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag not in ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == "a":
|
||||||
|
self.parts.append('<a href="__missing__">')
|
||||||
|
elif tag == "br":
|
||||||
|
self.parts.append("<br>")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.parts.append(f"<{tag}>")
|
||||||
|
self.open_tags.append(tag)
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if self.skip_depth:
|
||||||
|
return
|
||||||
|
attr_map = {name.lower(): value or "" for name, value in attrs}
|
||||||
|
if tag in SKIP_TAGS or self._has_skipped_class(attr_map.get("class", "")):
|
||||||
|
return
|
||||||
|
if tag == "br":
|
||||||
|
self.parts.append("<br>")
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if self.skip_depth:
|
||||||
|
self.skip_depth -= 1
|
||||||
|
return
|
||||||
|
if tag not in ALLOWED_TAGS or tag in VOID_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
for index in range(len(self.open_tags) - 1, -1, -1):
|
||||||
|
if self.open_tags[index] == tag:
|
||||||
|
while len(self.open_tags) > index:
|
||||||
|
closing_tag = self.open_tags.pop()
|
||||||
|
self.parts.append(f"</{closing_tag}>")
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self.skip_depth:
|
||||||
|
return
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
self.parts.append(html.escape(data, quote=False))
|
||||||
|
|
||||||
|
def handle_entityref(self, name: str) -> None:
|
||||||
|
if self.skip_depth:
|
||||||
|
return
|
||||||
|
self.parts.append(f"&{name};")
|
||||||
|
|
||||||
|
def handle_charref(self, name: str) -> None:
|
||||||
|
if self.skip_depth:
|
||||||
|
return
|
||||||
|
self.parts.append(f"&#{name};")
|
||||||
|
|
||||||
|
def get_html(self) -> str:
|
||||||
|
while self.open_tags:
|
||||||
|
self.parts.append(f"</{self.open_tags.pop()}>")
|
||||||
|
return "".join(self.parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_skipped_class(class_value: str) -> bool:
|
||||||
|
classes = class_value.lower().split()
|
||||||
|
return any(any(part in cls for part in SKIP_CLASS_PARTS) for cls in classes)
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_source(source: str) -> str:
|
||||||
|
parsed = urlparse(source)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
if "/wiki/" in parsed.path:
|
||||||
|
return unquote(parsed.path.rsplit("/wiki/", 1)[1]).replace("_", " ")
|
||||||
|
raise ValueError(f"Unsupported Wikipedia URL: {source}")
|
||||||
|
return source.replace("_", " ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_title(title: str) -> str:
|
||||||
|
text = unicodedata.normalize("NFD", title.strip().lower())
|
||||||
|
text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
|
||||||
|
text = text.replace("đ", "d")
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||||
|
return text.strip("-") or "wiki"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_wikipedia_html(title: str) -> tuple[str, str]:
|
||||||
|
response = requests.get(
|
||||||
|
API_URL,
|
||||||
|
params={
|
||||||
|
"action": "parse",
|
||||||
|
"page": title,
|
||||||
|
"prop": "text",
|
||||||
|
"format": "json",
|
||||||
|
"formatversion": "2",
|
||||||
|
"redirects": "1",
|
||||||
|
"disableeditsection": "1",
|
||||||
|
},
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if "error" in payload:
|
||||||
|
raise RuntimeError(json.dumps(payload["error"], ensure_ascii=False))
|
||||||
|
parsed = payload.get("parse") or {}
|
||||||
|
fetched_title = str(parsed.get("title") or title).strip()
|
||||||
|
article_html = str(parsed.get("text") or "")
|
||||||
|
if not article_html.strip():
|
||||||
|
raise RuntimeError(f"No article HTML returned for title: {title}")
|
||||||
|
return fetched_title, article_html
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_wikipedia_html(article_html: str) -> str:
|
||||||
|
parser = WikiHtmlSanitizer()
|
||||||
|
parser.feed(article_html)
|
||||||
|
parser.close()
|
||||||
|
content = html.unescape(parser.get_html())
|
||||||
|
content = normalize_fragment(content)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fragment(content: str) -> str:
|
||||||
|
content = re.sub(r"\r\n?", "\n", content)
|
||||||
|
content = re.sub(r"[ \t\f\v]+", " ", content)
|
||||||
|
content = re.sub(r"\s*\n\s*", "\n", content)
|
||||||
|
content = re.sub(r">\s+<", "><", content)
|
||||||
|
content = re.sub(r"<(p|li|h[2-6]|blockquote)>\s*</\1>", "", content)
|
||||||
|
content = re.sub(r"<(ul|ol)>\s*</\1>", "", content)
|
||||||
|
content = re.sub(r"(</(?:p|h[2-6]|ul|ol|li|blockquote|pre)>)", r"\1\n", content)
|
||||||
|
content = re.sub(r"\n{2,}", "\n", content)
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def put_first_paragraph_in_blockquote(content: str) -> str:
|
||||||
|
match = re.search(r"<p>(.*?)</p>", content, flags=re.S)
|
||||||
|
if not match:
|
||||||
|
return content
|
||||||
|
|
||||||
|
quote_inner = match.group(1).strip()
|
||||||
|
before = content[: match.start()].strip()
|
||||||
|
after = content[match.end() :].strip()
|
||||||
|
parts = []
|
||||||
|
if quote_inner:
|
||||||
|
parts.append(f"<blockquote>{quote_inner}</blockquote>")
|
||||||
|
if before:
|
||||||
|
parts.append(before)
|
||||||
|
if after:
|
||||||
|
parts.append(after)
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def write_article(source: str, output_dir: Path, output_name: str | None = None) -> Path:
|
||||||
|
title = title_from_source(source)
|
||||||
|
fetched_title, article_html = fetch_wikipedia_html(title)
|
||||||
|
content = sanitize_wikipedia_html(article_html)
|
||||||
|
content = put_first_paragraph_in_blockquote(content)
|
||||||
|
|
||||||
|
filename = output_name or f"{slugify_title(fetched_title)}.html"
|
||||||
|
if not filename.endswith(".html"):
|
||||||
|
filename = f"{filename}.html"
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = output_dir / filename
|
||||||
|
output_path.write_text(content + "\n", encoding="utf-8")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Fetch a Vietnamese Wikipedia article into UHM wiki HTML format.")
|
||||||
|
parser.add_argument("source", help="Vietnamese Wikipedia URL or page title.")
|
||||||
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||||
|
parser.add_argument("--output-name", help="Output filename. Defaults to a slug from the fetched title.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
output_path = write_article(args.source, args.output_dir, args.output_name)
|
||||||
|
print(output_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import Alert from "@/components/ui/alert/Alert";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Alerts | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Alerts page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
// other metadata
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Alerts() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Alerts" />
|
|
||||||
<div className="space-y-5 sm:space-y-6">
|
|
||||||
<ComponentCard title="Success Alert">
|
|
||||||
<Alert
|
|
||||||
variant="success"
|
|
||||||
title="Success Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={true}
|
|
||||||
linkHref="/"
|
|
||||||
linkText="Learn more"
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
variant="success"
|
|
||||||
title="Success Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={false}
|
|
||||||
/>
|
|
||||||
</ComponentCard>
|
|
||||||
<ComponentCard title="Warning Alert">
|
|
||||||
<Alert
|
|
||||||
variant="warning"
|
|
||||||
title="Warning Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={true}
|
|
||||||
linkHref="/"
|
|
||||||
linkText="Learn more"
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
variant="warning"
|
|
||||||
title="Warning Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={false}
|
|
||||||
/>
|
|
||||||
</ComponentCard>{" "}
|
|
||||||
<ComponentCard title="Error Alert">
|
|
||||||
<Alert
|
|
||||||
variant="error"
|
|
||||||
title="Error Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={true}
|
|
||||||
linkHref="/"
|
|
||||||
linkText="Learn more"
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
variant="error"
|
|
||||||
title="Error Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={false}
|
|
||||||
/>
|
|
||||||
</ComponentCard>{" "}
|
|
||||||
<ComponentCard title="Info Alert">
|
|
||||||
<Alert
|
|
||||||
variant="info"
|
|
||||||
title="Info Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={true}
|
|
||||||
linkHref="/"
|
|
||||||
linkText="Learn more"
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
variant="info"
|
|
||||||
title="Info Message"
|
|
||||||
message="Be cautious when performing this action."
|
|
||||||
showLink={false}
|
|
||||||
/>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import Avatar from "@/components/ui/avatar/Avatar";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Avatars | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Avatars page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AvatarPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Avatar" />
|
|
||||||
<div className="space-y-5 sm:space-y-6">
|
|
||||||
<ComponentCard title="Default Avatar">
|
|
||||||
{/* Default Avatar (No Status) */}
|
|
||||||
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="xsmall" />
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="small" />
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="medium" />
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="large" />
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="xlarge" />
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="xxlarge" />
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
<ComponentCard title="Avatar with online indicator">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xsmall"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="small"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="medium"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="large"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xlarge"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xxlarge"
|
|
||||||
status="online"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
<ComponentCard title="Avatar with Offline indicator">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xsmall"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="small"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="medium"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="large"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xlarge"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xxlarge"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>{" "}
|
|
||||||
<ComponentCard title="Avatar with busy indicator">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xsmall"
|
|
||||||
status="busy"
|
|
||||||
/>
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="small" status="busy" />
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="medium"
|
|
||||||
status="busy"
|
|
||||||
/>
|
|
||||||
<Avatar src="/images/user/user-01.jpg" size="large" status="busy" />
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xlarge"
|
|
||||||
status="busy"
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
src="/images/user/user-01.jpg"
|
|
||||||
size="xxlarge"
|
|
||||||
status="busy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
|
||||||
import { PlusIcon } from "@/icons";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Badge | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Badge page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
// other metadata
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BadgePage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Badges" />
|
|
||||||
<div className="space-y-5 sm:space-y-6">
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
With Light Background
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
{/* Light Variant */}
|
|
||||||
<Badge variant="light" color="primary">
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="success">
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="error">
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="warning">
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="info">
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="light">
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="dark">
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
With Solid Background
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
{/* Light Variant */}
|
|
||||||
<Badge variant="solid" color="primary">
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="success">
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="error">
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="warning">
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="info">
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="light">
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="dark">
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Light Background with Left Icon
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
<Badge variant="light" color="primary" startIcon={<PlusIcon />}>
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="success" startIcon={<PlusIcon />}>
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="error" startIcon={<PlusIcon />}>
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="warning" startIcon={<PlusIcon />}>
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="info" startIcon={<PlusIcon />}>
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="light" startIcon={<PlusIcon />}>
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="dark" startIcon={<PlusIcon />}>
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Solid Background with Left Icon
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
<Badge variant="solid" color="primary" startIcon={<PlusIcon />}>
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="success" startIcon={<PlusIcon />}>
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="error" startIcon={<PlusIcon />}>
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="warning" startIcon={<PlusIcon />}>
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="info" startIcon={<PlusIcon />}>
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="light" startIcon={<PlusIcon />}>
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="dark" startIcon={<PlusIcon />}>
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Light Background with Right Icon
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
<Badge variant="light" color="primary" endIcon={<PlusIcon />}>
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="success" endIcon={<PlusIcon />}>
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="error" endIcon={<PlusIcon />}>
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="warning" endIcon={<PlusIcon />}>
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="light" color="info" endIcon={<PlusIcon />}>
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="light" endIcon={<PlusIcon />}>
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="light" color="dark" endIcon={<PlusIcon />}>
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Solid Background with Right Icon
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
|
|
||||||
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
|
|
||||||
<Badge variant="solid" color="primary" endIcon={<PlusIcon />}>
|
|
||||||
Primary
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="success" endIcon={<PlusIcon />}>
|
|
||||||
Success
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="error" endIcon={<PlusIcon />}>
|
|
||||||
Error
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="warning" endIcon={<PlusIcon />}>
|
|
||||||
Warning
|
|
||||||
</Badge>{" "}
|
|
||||||
<Badge variant="solid" color="info" endIcon={<PlusIcon />}>
|
|
||||||
Info
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="light" endIcon={<PlusIcon />}>
|
|
||||||
Light
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="solid" color="dark" endIcon={<PlusIcon />}>
|
|
||||||
Dark
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import Button from "@/components/ui/button/Button";
|
|
||||||
import { BoxIcon } from "@/icons";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Buttons | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Buttons page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Buttons() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Buttons" />
|
|
||||||
<div className="space-y-5 sm:space-y-6">
|
|
||||||
{/* Primary Button */}
|
|
||||||
<ComponentCard title="Primary Button">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Button size="sm" variant="primary">
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="primary">
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
{/* Primary Button with Start Icon */}
|
|
||||||
<ComponentCard title="Primary Button with Left Icon">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Button size="sm" variant="primary" startIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="primary" startIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>{" "}
|
|
||||||
{/* Primary Button with Start Icon */}
|
|
||||||
<ComponentCard title="Primary Button with Right Icon">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Button size="sm" variant="primary" endIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="primary" endIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
{/* Outline Button */}
|
|
||||||
<ComponentCard title="Secondary Button">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
{/* Outline Button */}
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="outline">
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
{/* Outline Button with Start Icon */}
|
|
||||||
<ComponentCard title="Outline Button with Left Icon">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Button size="sm" variant="outline" startIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="outline" startIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>{" "}
|
|
||||||
{/* Outline Button with Start Icon */}
|
|
||||||
<ComponentCard title="Outline Button with Right Icon">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Button size="sm" variant="outline" endIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="outline" endIcon={<BoxIcon />}>
|
|
||||||
Button Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import ResponsiveImage from "@/components/ui/images/ResponsiveImage";
|
|
||||||
import ThreeColumnImageGrid from "@/components/ui/images/ThreeColumnImageGrid";
|
|
||||||
import TwoColumnImageGrid from "@/components/ui/images/TwoColumnImageGrid";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Images | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Images page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
// other metadata
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Images() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Images" />
|
|
||||||
<div className="space-y-5 sm:space-y-6">
|
|
||||||
<ComponentCard title="Responsive image">
|
|
||||||
<ResponsiveImage />
|
|
||||||
</ComponentCard>
|
|
||||||
<ComponentCard title="Image in 2 Grid">
|
|
||||||
<TwoColumnImageGrid />
|
|
||||||
</ComponentCard>
|
|
||||||
<ComponentCard title="Image in 3 Grid">
|
|
||||||
<ThreeColumnImageGrid />
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import DefaultModal from "@/components/example/ModalExample/DefaultModal";
|
|
||||||
import FormInModal from "@/components/example/ModalExample/FormInModal";
|
|
||||||
import FullScreenModal from "@/components/example/ModalExample/FullScreenModal";
|
|
||||||
import ModalBasedAlerts from "@/components/example/ModalExample/ModalBasedAlerts";
|
|
||||||
import VerticallyCenteredModal from "@/components/example/ModalExample/VerticallyCenteredModal";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Modals | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Modals page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
// other metadata
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Modals() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Modals" />
|
|
||||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2 xl:gap-6">
|
|
||||||
<DefaultModal />
|
|
||||||
<VerticallyCenteredModal />
|
|
||||||
<FormInModal />
|
|
||||||
<FullScreenModal />
|
|
||||||
<ModalBasedAlerts />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import VideosExample from "@/components/ui/video/VideosExample";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Videos | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Videos page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function VideoPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Videos" />
|
|
||||||
|
|
||||||
<VideosExample />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -46,7 +44,7 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Sử dụng tông màu Vàng cổ (Parchment) và Xanh rêu (Dark Slate Green)
|
// 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">
|
<div className="relative min-h-screen max-w-[1200px] mx-auto text-[#2D3A3A] font-sans selection:bg-[#A88B4C] selection:text-white overflow-x-hidden">
|
||||||
{/* --- BACKGROUND IMAGE --- */}
|
{/* --- BACKGROUND IMAGE --- */}
|
||||||
<div className="fixed inset-0 -z-20 pointer-events-none">
|
<div className="fixed inset-0 -z-20 pointer-events-none">
|
||||||
<Image
|
<Image
|
||||||
@@ -61,27 +59,19 @@ export default function LandingPage() {
|
|||||||
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
|
<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 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">
|
<header className="sticky top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-40 border-b border-[#A88B4C]/20">
|
||||||
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
|
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
|
||||||
<span className="text-[#A88B4C]">Geo</span>History
|
<span className="text-[#A88B4C]">Geo</span>History
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-4 items-center">
|
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signin"
|
href="/"
|
||||||
className="text-sm font-semibold text-[#2D3A3A] hover:text-[#A88B4C] transition-colors"
|
className="rounded-xl border border-[#A88B4C]/30 bg-[#FDFBF7]/80 px-4 py-2 text-sm font-bold text-[#2D3A3A] transition hover:bg-[#A88B4C] hover:text-white"
|
||||||
>
|
>
|
||||||
Đăng nhập
|
Quay lại bản đồ
|
||||||
</Link>
|
</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>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col gap-32 w-full relative">
|
<main className="max-w-6xl mx-auto px-6 pt-8 pb-24 flex flex-col gap-32 w-full relative">
|
||||||
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
|
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
|
||||||
<section className="min-h-[70vh] flex flex-col justify-center relative">
|
<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">
|
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
|
||||||
@@ -110,6 +100,12 @@ export default function LandingPage() {
|
|||||||
>
|
>
|
||||||
Khám phá sứ mệnh
|
Khám phá sứ mệnh
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="px-8 py-4 border border-[#A88B4C]/30 bg-[#FDFBF7]/80 text-[#2D3A3A] font-bold rounded-xl hover:bg-[#2D3A3A] hover:text-white"
|
||||||
|
>
|
||||||
|
Về bản đồ
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1,7 +1,131 @@
|
|||||||
import { redirect } from "next/navigation";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { fetchProjects, type Project } from "@/uhm/api/projects";
|
||||||
|
|
||||||
export default function EditorIndexPage() {
|
export default function EditorIndexPage() {
|
||||||
// Editor must be opened from a specific project (see /user/projects).
|
const router = useRouter();
|
||||||
redirect("/user/projects");
|
// State danh sách project mà user hiện tại có quyền mở trong editor.
|
||||||
}
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
// State loading cho lần tải đầu của route /editor.
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
// State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ.
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm.
|
||||||
|
const sortedProjects = useMemo(() => {
|
||||||
|
return [...projects].sort((a, b) => {
|
||||||
|
const aTime = Date.parse(a.updated_at || a.created_at || "");
|
||||||
|
const bTime = Date.parse(b.updated_at || b.created_at || "");
|
||||||
|
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
||||||
|
});
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ.
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const rows = await fetchProjects();
|
||||||
|
if (!disposed) setProjects(rows || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "Không tải được danh sách project.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadProjects();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: "100vh", background: "#0b1220", color: "#e5e7eb", padding: 24 }}>
|
||||||
|
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800 }}>Editor</h1>
|
||||||
|
<p style={{ margin: "8px 0 0", color: "#94a3b8", fontSize: 14 }}>
|
||||||
|
Chọn project để mở route <code>/editor/[id]</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/user/projects")}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "10px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quản lý project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
borderRadius: 16,
|
||||||
|
background: "#111827",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: 24, color: "#94a3b8" }}>Đang tải project...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: 24, color: "#fecaca" }}>{error}</div>
|
||||||
|
) : sortedProjects.length === 0 ? (
|
||||||
|
<div style={{ padding: 24, color: "#94a3b8" }}>
|
||||||
|
Chưa có project. Vào trang quản lý project để tạo mới.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid" }}>
|
||||||
|
{sortedProjects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/editor/${project.id}`)}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 6,
|
||||||
|
textAlign: "left",
|
||||||
|
border: 0,
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
padding: 16,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 800 }}>
|
||||||
|
{project.title || `Project ${project.id}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||||
|
{project.project_status || "ACTIVE"} · {project.id}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type GuideSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
steps: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const guideSections: GuideSection[] = [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
title: 'Bắt đầu xem bản đồ',
|
||||||
|
summary: 'Route / là màn bản đồ lịch sử tương tác. Người dùng có thể xem dữ liệu theo năm, chọn lớp hiển thị, mở wiki và phát replay sự kiện.',
|
||||||
|
steps: [
|
||||||
|
'Khi vào trang chủ, chờ bản đồ tải xong hoặc nhấn vào màn hình chờ để vào nhanh hơn.',
|
||||||
|
'Dùng chuột để kéo bản đồ, cuộn để phóng to/thu nhỏ. Trên điện thoại, dùng một ngón để kéo và hai ngón để phóng to/thu nhỏ bản đồ.',
|
||||||
|
'Các vùng, đường, điểm hoặc biểu tượng trên bản đồ là dữ liệu lịch sử. Nhấn vào một đối tượng để xem thông tin liên quan.',
|
||||||
|
'Nếu bản đồ đang tải dữ liệu theo năm, chờ vài giây để các lớp lịch sử cập nhật.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timeline',
|
||||||
|
title: 'Dùng thanh thời gian',
|
||||||
|
summary: 'Thanh thời gian ở cạnh dưới dùng để chuyển bản đồ về một mốc năm cụ thể.',
|
||||||
|
steps: [
|
||||||
|
'Kéo thước timeline để đổi năm nhanh.',
|
||||||
|
'Nhập trực tiếp năm vào ô số nếu đã biết mốc cần xem.',
|
||||||
|
'Nhấn nút - hoặc + để giảm/tăng từng năm. Giữ nút để chạy liên tục.',
|
||||||
|
'Bật lọc timeline để chỉ ưu tiên dữ liệu phù hợp với năm đang chọn. Tắt lọc nếu muốn xem nhiều dữ liệu hơn cùng lúc.',
|
||||||
|
'Trên màn hình desktop có ô Range. Tăng Range nếu muốn mở rộng khoảng năm gần mốc đang chọn, ví dụ xem thêm dữ liệu trong vài năm lân cận.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
title: 'Tìm địa danh và dữ liệu lịch sử',
|
||||||
|
summary: 'Ô tìm kiếm giúp đi nhanh tới địa danh hiện tại, wiki hoặc geometry lịch sử.',
|
||||||
|
steps: [
|
||||||
|
'Nhập tên địa danh, nhân vật, sự kiện hoặc thực thể lịch sử vào ô tìm kiếm phía trên bản đồ.',
|
||||||
|
'Chọn kết quả phù hợp để bản đồ tự di chuyển tới vị trí liên quan.',
|
||||||
|
'Nếu kết quả là địa danh hiện tại, bản đồ sẽ focus tới tọa độ hiện nay.',
|
||||||
|
'Nếu kết quả có geometry lịch sử, hệ thống sẽ chọn đối tượng đó và có thể tự đổi timeline về năm bắt đầu của dữ liệu.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'layers',
|
||||||
|
title: 'Bật/tắt lớp bản đồ',
|
||||||
|
summary: 'Bảng lớp nằm bên trái, dùng để kiểm soát nền bản đồ và loại dữ liệu lịch sử đang hiển thị.',
|
||||||
|
steps: [
|
||||||
|
'Dùng nhóm lớp nền để đổi hoặc ẩn/hiện các nền như bản đồ cơ sở, vệ tinh hoặc các lớp tham chiếu.',
|
||||||
|
'Dùng nhóm geometry để bật/tắt từng loại dữ liệu như quốc gia, vùng, thành phố, tuyến đường, trận đánh, cảng, đền, pháo đài.',
|
||||||
|
'Nếu bản đồ quá nhiều chi tiết, hãy tắt bớt loại geometry chưa cần xem.',
|
||||||
|
'Có thể ẩn bảng lớp để mở rộng diện tích bản đồ. Mở menu tròn bên trái rồi nhấn nút hiện bảng lớp để bật lại.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wiki',
|
||||||
|
title: 'Mở wiki từ bản đồ',
|
||||||
|
summary: 'Khi chọn một đối tượng lịch sử, panel bên phải hoặc phía dưới trên mobile sẽ hiển thị thông tin wiki liên quan.',
|
||||||
|
steps: [
|
||||||
|
'Nhấn vào vùng, điểm hoặc đường trên bản đồ để chọn đối tượng.',
|
||||||
|
'Nếu đối tượng có nhiều wiki liên quan, chọn bài viết phù hợp trong danh sách.',
|
||||||
|
'Trong nội dung wiki, nhấn các liên kết nội bộ để mở bài khác. Nếu bài đó có nhiều geometry, chọn geometry muốn focus.',
|
||||||
|
'Kéo cạnh panel để đổi kích thước trên desktop. Trên mobile, panel nằm phía dưới và có thể điều chỉnh chiều cao.',
|
||||||
|
'Nhấn nút đóng trong panel để quay lại chế độ xem bản đồ rộng hơn.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'replay',
|
||||||
|
title: 'Xem replay diễn biến lịch sử',
|
||||||
|
summary: 'Replay mô phỏng các bước diễn biến của một sự kiện hoặc trận đánh khi dữ liệu đó có kịch bản.',
|
||||||
|
steps: [
|
||||||
|
'Chọn một đối tượng trên bản đồ. Nếu đối tượng có replay, nút phát ở thanh thời gian sẽ khả dụng.',
|
||||||
|
'Nhấn nút phát để bắt đầu xem diễn biến.',
|
||||||
|
'Trong lúc replay chạy, bản đồ có thể tự di chuyển, đổi năm, làm nổi bật đường đi, vùng ảnh hưởng hoặc các điểm quan trọng.',
|
||||||
|
'Dùng các nút điều khiển replay để tạm dừng, phát lại, đổi tốc độ hoặc thoát khỏi chế độ replay.',
|
||||||
|
'Nếu không thấy nút phát hoạt động, đối tượng đang chọn có thể chưa có replay.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu',
|
||||||
|
title: 'Menu trợ giúp và tài khoản',
|
||||||
|
summary: 'Nút tròn bên trái mở các lối tắt tới tài khoản, chỉnh sửa, chatbot, FAQ và trang giới thiệu.',
|
||||||
|
steps: [
|
||||||
|
'Nhấn nút tròn avatar/người dùng ở bên trái để mở menu.',
|
||||||
|
'Nút bút chì đưa tới khu vực quản trị và chỉnh sửa. Tính năng này chỉ hỗ trợ tốt trên desktop.',
|
||||||
|
'Nút tia sét bật chế độ tải nhanh hơn cho lần vào sau.',
|
||||||
|
'Nút chat mở trợ lý AI lịch sử để hỏi nhanh về dữ liệu hoặc sự kiện.',
|
||||||
|
'Nút quyển sách mở trang hướng dẫn này. Nút thông tin mở trang giới thiệu dự án.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
title: 'Lưu ý khi dùng trên điện thoại',
|
||||||
|
summary: 'Giao diện mobile ưu tiên bản đồ toàn màn hình, các panel và control sẽ được thu gọn.',
|
||||||
|
steps: [
|
||||||
|
'Thanh timeline trên mobile nằm phía dưới, gồm ô năm, nút -/+, công tắc lọc và thước kéo.',
|
||||||
|
'Bảng wiki mở ở phía dưới màn hình để không che toàn bộ bản đồ.',
|
||||||
|
'Một số thao tác quản trị hoặc chỉnh sửa bị khóa trên mobile để tránh lỗi thao tác.',
|
||||||
|
'Nếu khó chọn một đối tượng nhỏ, hãy phóng to bản đồ trước rồi nhấn lại.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickTips = [
|
||||||
|
'Muốn xem dữ liệu theo một năm cụ thể: nhập năm ở thanh timeline.',
|
||||||
|
'Muốn hiểu một vùng trên bản đồ: nhấn vào vùng đó để mở wiki.',
|
||||||
|
'Muốn bản đồ đỡ rối: tắt bớt lớp geometry bên trái.',
|
||||||
|
'Muốn xem diễn biến: chọn đối tượng có replay rồi nhấn nút phát.',
|
||||||
|
'Muốn hỏi nhanh: mở menu bên trái và chọn chatbot.'
|
||||||
|
];
|
||||||
|
|
||||||
|
const troubleshooting = [
|
||||||
|
{
|
||||||
|
question: 'Tại sao tôi không thấy dữ liệu sau khi đổi năm?',
|
||||||
|
answer: 'Có thể năm đang chọn không có geometry phù hợp hoặc bộ lọc timeline đang bật. Hãy thử tắt lọc timeline, tăng Range trên desktop hoặc chuyển sang năm gần hơn với sự kiện bạn đang xem.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Tại sao nhấn vào bản đồ nhưng không mở wiki?',
|
||||||
|
answer: 'Một số geometry có thể chưa liên kết wiki hoặc bạn đang nhấn vào nền bản đồ. Hãy phóng to hơn, nhấn trực tiếp vào vùng/đường/biểu tượng, hoặc thử tìm bằng ô tìm kiếm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Tại sao không phát được replay?',
|
||||||
|
answer: 'Replay chỉ có ở những đối tượng đã được biên soạn kịch bản. Nếu nút phát không phản hồi, hãy chọn một đối tượng khác hoặc tìm các sự kiện/trận đánh đã có dữ liệu replay.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Tại sao bản đồ tải chậm?',
|
||||||
|
answer: 'Bản đồ cần tải nền, geometry, wiki và quan hệ dữ liệu. Bạn có thể bật chế độ tải nhanh trong menu bên trái, giảm số lớp đang bật hoặc chờ bản đồ tải xong trước khi thao tác liên tục.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Tôi muốn chỉnh sửa hoặc đóng góp dữ liệu thì làm thế nào?',
|
||||||
|
answer: 'Mở menu bên trái và vào khu vực quản trị/chỉnh sửa trên desktop. Nếu chưa có quyền phù hợp, hãy đăng nhập và gửi yêu cầu nâng quyền theo luồng trong tài khoản người dùng.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [openSection, setOpenSection] = useState<string>('start');
|
||||||
|
const [openTrouble, setOpenTrouble] = useState<number | null>(0);
|
||||||
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
const handleGuideNavClick = (sectionId: string) => {
|
||||||
|
setOpenSection(sectionId);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-50 text-slate-950">
|
||||||
|
<section className="border-b border-slate-200 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-10 sm:px-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-blue-700">Hướng dẫn sử dụng</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Ultimate History Map FAQ</h1>
|
||||||
|
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||||
|
Trang này hướng dẫn cách sử dụng màn bản đồ tại route <span className="font-semibold text-slate-900">/</span>: xem lịch sử theo timeline, tìm kiếm, bật/tắt lớp, đọc wiki và phát replay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-11 items-center justify-center rounded-md bg-slate-900 px-5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Quay lại bản đồ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto grid max-w-6xl gap-6 px-4 py-8 sm:px-8 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
|
<nav className="lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
|
||||||
|
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Mục hướng dẫn</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{guideSections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleGuideNavClick(section.id)}
|
||||||
|
className={`rounded-md px-3 py-2 text-left text-sm font-medium transition ${
|
||||||
|
openSection === section.id
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-950'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<h2 className="text-xl font-bold">Dùng nhanh trong 1 phút</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{quickTips.map((tip, index) => (
|
||||||
|
<div key={tip} className="flex gap-3 rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-6 text-slate-700">{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
{guideSections.map((section) => {
|
||||||
|
const isOpen = openSection === section.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current[section.id] = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-6 border-b border-slate-200 last:border-b-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSection(isOpen ? '' : section.id)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 px-5 py-5 text-left transition hover:bg-slate-50 sm:px-6"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="block text-lg font-bold text-slate-950">{section.title}</span>
|
||||||
|
<span className="mt-1 block text-sm leading-6 text-slate-600">{section.summary}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-2xl font-light text-blue-700">{isOpen ? '-' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ${isOpen ? 'max-h-[620px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<ol className="space-y-3 px-5 pb-6 sm:px-6">
|
||||||
|
{section.steps.map((step, index) => (
|
||||||
|
<li key={step} className="flex gap-3 text-sm leading-6 text-slate-700">
|
||||||
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-700">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{step}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<h2 className="text-xl font-bold">Lỗi thường gặp</h2>
|
||||||
|
<div className="mt-4 divide-y divide-slate-200">
|
||||||
|
{troubleshooting.map((item, index) => {
|
||||||
|
const isOpen = openTrouble === index;
|
||||||
|
return (
|
||||||
|
<div key={item.question}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenTrouble(isOpen ? null : index)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 py-4 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-slate-900">{item.question}</span>
|
||||||
|
<span className="shrink-0 text-2xl font-light text-blue-700">{isOpen ? '-' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ${isOpen ? 'max-h-40 opacity-100 pb-4' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<p className="text-sm leading-6 text-slate-600">{item.answer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import './fonts.css';
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-*: initial;
|
--font-*: initial;
|
||||||
--font-outfit: Outfit, sans-serif;
|
--font-outfit: Outfit, sans-serif;
|
||||||
--font-inter: "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
--font-inter: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
--breakpoint-*: initial;
|
--breakpoint-*: initial;
|
||||||
--breakpoint-2xsm: 375px;
|
--breakpoint-2xsm: 375px;
|
||||||
@@ -743,12 +741,7 @@ span.flatpickr-weekday,
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scrollbar-gutter: stable;
|
overflow-y: auto;
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -822,3 +815,11 @@ html {
|
|||||||
.dark .ql-editor {
|
.dark .ql-editor {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix scrollbar jump for modals (SweetAlert2, Headless UI, etc.) */
|
||||||
|
body.swal2-shown,
|
||||||
|
body.modal-open,
|
||||||
|
body[data-scroll-locked] {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
@@ -1,47 +1,38 @@
|
|||||||
import localFont from 'next/font/local';
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import "flatpickr/dist/flatpickr.css";
|
|
||||||
import { SidebarProvider } from '@/context/SidebarContext';
|
|
||||||
import { ThemeProvider } from '@/context/ThemeContext';
|
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import StoreProvider from '@/store/StoreProvider';
|
import StoreProvider from '@/store/StoreProvider';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
const sfPro = localFont({
|
export const metadata: Metadata = {
|
||||||
src: [
|
title: 'Ultimate History Map',
|
||||||
{
|
description: 'Bản đồ tương tác lịch sử thế giới qua các thời kỳ',
|
||||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Regular.otf',
|
};
|
||||||
weight: '400',
|
|
||||||
style: 'normal',
|
const inter = Inter({
|
||||||
},
|
subsets: ['latin', 'vietnamese'],
|
||||||
{
|
weight: ['400', '500', '600', '700'],
|
||||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Medium.otf',
|
variable: '--font-inter',
|
||||||
weight: '500',
|
display: 'swap',
|
||||||
style: 'normal',
|
preload: false,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf',
|
export default async function RootLayout({
|
||||||
weight: '600',
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Bold.otf',
|
|
||||||
weight: '700',
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const pathname = headersList.get('x-pathname') || '/';
|
||||||
|
const isPublicRoot = pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={isPublicRoot ? '' : inter.variable}>
|
||||||
<body className={`${sfPro.className} dark:bg-gray-900`}>
|
<body className={`${isPublicRoot ? 'font-sans' : inter.className} dark:bg-gray-900`}>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<ThemeProvider>
|
{children}
|
||||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
<Toaster closeButton richColors position="top-right" />
|
||||||
</ThemeProvider>
|
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,791 +1,63 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PublicPreviewWrapper from "@/uhm/components/preview/PublicPreviewWrapper";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
export const metadata: Metadata = {
|
||||||
|
title: "Ultimate History Map | Bản Đồ Lịch Sử Thế Giới Tương Tác",
|
||||||
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
|
description: "Khám phá lịch sử thế giới qua bản đồ tương tác theo dòng thời gian. Xem lại các trận đánh diễn biến lịch sử sinh động qua hệ thống Replay.",
|
||||||
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 { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
|
||||||
import {
|
|
||||||
BACKGROUND_LAYER_OPTIONS,
|
|
||||||
type BackgroundLayerId,
|
|
||||||
type BackgroundLayerVisibility,
|
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
|
||||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
|
||||||
import {
|
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
|
||||||
persistBackgroundLayerVisibility,
|
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
|
||||||
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 = {
|
const srOnlyStyle: React.CSSProperties = {
|
||||||
slug: string;
|
position: "absolute",
|
||||||
entities: Entity[];
|
width: "1px",
|
||||||
top: number;
|
height: "1px",
|
||||||
left: number;
|
padding: "0",
|
||||||
};
|
margin: "-1px",
|
||||||
|
overflow: "hidden",
|
||||||
const EMPTY_RELATIONS: RelationIndex = {
|
clip: "rect(0, 0, 0, 0)",
|
||||||
entitiesById: {},
|
whiteSpace: "nowrap",
|
||||||
entityGeometriesById: {},
|
border: "0",
|
||||||
entityWikisById: {},
|
|
||||||
geometryEntityIds: {},
|
|
||||||
wikiEntityIdsBySlug: {},
|
|
||||||
wikiBySlug: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
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 })
|
|
||||||
);
|
|
||||||
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);
|
|
||||||
|
|
||||||
const timelineFetchRequestRef = useRef(0);
|
|
||||||
const hoverHideTimerRef = useRef<number | null>(null);
|
|
||||||
const hoverPopupHoveredRef = useRef(false);
|
|
||||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const selectedFeature = useMemo(() => {
|
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
|
|
||||||
return (
|
return (
|
||||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
|
<div style={{ position: "relative", width: "100%", height: "100svh", overflow: "hidden" }}>
|
||||||
);
|
{/* Preload LCP image */}
|
||||||
}, [data.features, selectedFeatureIds]);
|
<link rel="preload" as="image" href="/images/map_placeholder.webp" fetchPriority="high" />
|
||||||
|
|
||||||
useEffect(() => {
|
{/* Header (SSR & SEO) */}
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
<header style={srOnlyStyle}>
|
||||||
const stillExistIds = selectedFeatureIds.filter(id =>
|
<nav>
|
||||||
data.features.some(feature => String(feature.properties.id) === String(id))
|
<Link href="/">Trang chủ</Link>
|
||||||
);
|
<Link href="/faq">Hướng dẫn / Hỏi đáp</Link>
|
||||||
if (stillExistIds.length !== selectedFeatureIds.length) {
|
<Link href="/about-us">Về chúng tôi</Link>
|
||||||
setSelectedFeatureIds(stillExistIds);
|
<Link href="/user">Quản trị viên</Link>
|
||||||
}
|
</nav>
|
||||||
}, [data.features, selectedFeatureIds]);
|
</header>
|
||||||
|
|
||||||
useEffect(() => {
|
{/* Main Content & Semantic Heading (SSR & SEO) */}
|
||||||
const timeoutId = window.setTimeout(() => {
|
<main style={{ position: "relative", zIndex: 1, width: "100%", height: "100%" }}>
|
||||||
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
|
<div style={srOnlyStyle}>
|
||||||
}, TIMELINE_DEBOUNCE_MS);
|
<h1>Ultimate History Map - Bản Đồ Tương Tác Lịch Sử</h1>
|
||||||
return () => window.clearTimeout(timeoutId);
|
<p>
|
||||||
}, [timelineDraftYear, timelineYear]);
|
Dự án Ultimate History Map cung cấp cái nhìn trực quan và sinh động về sự thay đổi biên giới, các quốc gia, sự kiện lịch sử thế giới theo từng năm.
|
||||||
|
</p>
|
||||||
useEffect(() => {
|
<p>
|
||||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
Tính năng chính bao gồm:
|
||||||
setIsBackgroundVisibilityReady(true);
|
- Xem bản đồ lịch sử theo dòng thời gian.
|
||||||
}, []);
|
- Trình phát diễn biến lịch sử và chiến trận.
|
||||||
|
- Tra cứu thông tin sự kiện lịch sử.
|
||||||
useEffect(() => {
|
</p>
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<div className="grid gap-4 px-4 py-4">
|
{/* Stateful Interactive Client Component */}
|
||||||
<div>
|
<PublicPreviewWrapper />
|
||||||
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
</main>
|
||||||
<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>
|
{/* Footer (SSR & SEO) */}
|
||||||
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
<footer style={srOnlyStyle}>
|
||||||
Geometry
|
<p>© {new Date().getFullYear()} Ultimate History Map. Đã đăng ký bản quyền.</p>
|
||||||
</div>
|
</footer>
|
||||||
<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>
|
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import BarChartOne from "@/components/charts/bar/BarChartOne";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Bar Chart | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Bar Chart page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Bar Chart" />
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ComponentCard title="Bar Chart 1">
|
|
||||||
<BarChartOne />
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import LineChartOne from "@/components/charts/line/LineChartOne";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Next.js Line Chart | TailAdmin - Next.js Dashboard Template",
|
|
||||||
description:
|
|
||||||
"This is Next.js Line Chart page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
|
|
||||||
};
|
|
||||||
export default function LineChart() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<PageBreadcrumb pageTitle="Line Chart" />
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ComponentCard title="Line Chart 1">
|
|
||||||
<LineChartOne />
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import StickyHeader from "@/components/ui/StickyHeader";
|
|
||||||
import AccountDetails from "@/components/user-profile/AccountDetails";
|
|
||||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
|
||||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
|
||||||
import { UserMetaCardProps } from "@/interface/user";
|
|
||||||
import { apiGetCurrentUser } from "@/service/auth";
|
|
||||||
import { setUserData } from "@/store/features/userSlice";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
|
|
||||||
export default function Profile() {
|
|
||||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUser = async () => {
|
|
||||||
try {
|
|
||||||
const userData = await apiGetCurrentUser();
|
|
||||||
dispatch(setUserData(userData.data));
|
|
||||||
setUser(userData);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Lỗi:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchUser();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<StickyHeader header={`Thông tin tài khoản`} />
|
|
||||||
<div className="p-6 my-8 flex mx-auto ">
|
|
||||||
<div className="max-w-82 pr-4 border-r border-gray-300">
|
|
||||||
<UserMetaCard data={user ?? {}} />
|
|
||||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
|
||||||
<AccountDetails data={user ?? {}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSidebar } from "@/context/SidebarContext";
|
import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
|
||||||
import AppHeader from "@/layout/AppHeader";
|
import { ThemeProvider } from "@/context/ThemeContext";
|
||||||
import AppSidebar from "@/layout/AppSidebar";
|
import AppSidebar from "@/layout/AppSidebar";
|
||||||
import Backdrop from "@/layout/Backdrop";
|
import Backdrop from "@/layout/Backdrop";
|
||||||
import { apiGetCurrentUser } from "@/service/auth";
|
import { apiGetCurrentUser } from "@/service/auth";
|
||||||
@@ -13,6 +13,20 @@ export default function AdminLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AdminLayoutContent>{children}</AdminLayoutContent>
|
||||||
|
</SidebarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLayoutContent({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -27,14 +41,14 @@ export default function AdminLayout({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, [])
|
}, [dispatch])
|
||||||
|
|
||||||
|
|
||||||
const mainContentMargin = isMobileOpen
|
const mainContentMargin = isMobileOpen
|
||||||
? "ml-0"
|
? "ml-0"
|
||||||
: isExpanded || isHovered
|
: isExpanded || isHovered
|
||||||
? "lg:ml-[290px]"
|
? "lg:ml-[290px]"
|
||||||
: "lg:ml-[0px]";
|
: "lg:ml-[88px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen xl:flex">
|
<div className="min-h-screen xl:flex">
|
||||||
@@ -44,7 +58,7 @@ export default function AdminLayout({
|
|||||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||||
>
|
>
|
||||||
{/* <AppHeader /> */}
|
{/* <AppHeader /> */}
|
||||||
<div className="mx-auto max-w-(--breakpoint-2xl)">{children}</div>
|
<div className="mx-auto p-4 pt-0 max-w-[1240px]">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotWidget />
|
<ChatbotWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,126 @@
|
|||||||
import type { Metadata } from "next";
|
"use client";
|
||||||
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
|
|
||||||
import React from "react";
|
|
||||||
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
|
|
||||||
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
|
|
||||||
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
|
|
||||||
import RecentOrders from "@/components/ecommerce/RecentOrders";
|
|
||||||
import DemographicCard from "@/components/ecommerce/DemographicCard";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import AccountDetails from "@/components/user-profile/AccountDetails";
|
||||||
title:
|
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||||
"Home Page",
|
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||||
description: "This is Dashboard Home for History Web",
|
import { UserMetaCardProps } from "@/interface/user";
|
||||||
};
|
import { apiGetCurrentUser } from "@/service/auth";
|
||||||
|
import { setUserData } from "@/store/features/userSlice";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { RootState } from "@/store/store";
|
||||||
|
import StickyHeader from "@/components/ui/StickyHeader";
|
||||||
|
import { SafeHTMLRenderer } from "@/components/ui/parse/SafeHTMLRenderer";
|
||||||
|
import { apiGetCurrentUserApplications } from "@/service/userService";
|
||||||
|
import Loading from "@/app/loading";
|
||||||
|
|
||||||
export default function Ecommerce() {
|
export default function Profile() {
|
||||||
|
const currentUser = useSelector((state: RootState) => state.user.data);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [application, setApplication] = useState<any>(null);
|
||||||
|
const [appLoading, setAppLoading] = useState(false);
|
||||||
|
|
||||||
|
const isHistorian = !!currentUser?.roles?.some(
|
||||||
|
(role: any) => role.name === "HISTORIAN"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await apiGetCurrentUser();
|
||||||
|
dispatch(setUserData(userData.data));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Lỗi:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchUser();
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHistorian) {
|
||||||
|
const fetchApp = async () => {
|
||||||
|
try {
|
||||||
|
setAppLoading(true);
|
||||||
|
const res = await apiGetCurrentUserApplications();
|
||||||
|
if (res?.data) {
|
||||||
|
const approvedApp =
|
||||||
|
res.data.find((app: any) => app.status === "APPROVED") ||
|
||||||
|
res.data[0];
|
||||||
|
setApplication(approvedApp);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Lỗi khi tải hồ sơ nhà sử học:", err);
|
||||||
|
} finally {
|
||||||
|
setAppLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchApp();
|
||||||
|
}
|
||||||
|
}, [isHistorian]);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMetaProps: UserMetaCardProps = {
|
||||||
|
data: currentUser
|
||||||
|
? {
|
||||||
|
id: currentUser.id,
|
||||||
|
email: currentUser.email,
|
||||||
|
profile: currentUser.profile,
|
||||||
|
roles: currentUser.roles?.map((role) => ({
|
||||||
|
id: Number(role.id) || undefined,
|
||||||
|
name: role.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
status: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nếu người dùng có role là HISTORIAN
|
||||||
|
if (isHistorian) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
<div>
|
||||||
<div className="col-span-12 space-y-6 xl:col-span-7">
|
<StickyHeader header={`Thông tin tài khoản`} />
|
||||||
<EcommerceMetrics />
|
<div className="md:px-12 flex flex-col md:flex-row mx-auto gap-6 w-full max-w-7xl items-start">
|
||||||
|
<div className="w-full md:max-w-72 xl:max-w-82 pr-0 md:pr-4 border-b md:border-b-0 md:border-r border-gray-300 pb-6 md:pb-0 shrink-0 space-y-6">
|
||||||
<MonthlySalesChart />
|
<UserMetaCard data={userMetaProps} />
|
||||||
|
<UserInfoCard data={{ ...userMetaProps, openEdit: true }} />
|
||||||
|
<AccountDetails data={userMetaProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-5">
|
<div className="flex-1 min-w-0 w-full">
|
||||||
<MonthlyTarget />
|
{appLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-20 w-full bg-zinc-50/50 dark:bg-zinc-950/30 rounded-2xl border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="w-8 h-8 border-4 border-zinc-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
) : application ? (
|
||||||
<div className="col-span-12">
|
<div className="">
|
||||||
<StatisticsChart />
|
<SafeHTMLRenderer html={application.content} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="col-span-12 xl:col-span-5">
|
<div className="p-10 text-center text-zinc-500 font-medium bg-zinc-50/50 dark:bg-zinc-950/30 rounded-2xl border-2 border-zinc-50 dark:border-zinc-800">
|
||||||
<DemographicCard />
|
Không tìm thấy thông tin hồ sơ nhà sử học.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-7">
|
return (
|
||||||
<RecentOrders />
|
<div>
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6 mt-[100px]">
|
||||||
|
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||||
|
Thông tin tài khoản
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<UserMetaCard data={userMetaProps} />
|
||||||
|
<UserInfoCard data={{ ...userMetaProps, openEdit: true }} />
|
||||||
|
<AccountDetails data={userMetaProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ export default function ProjectDetailsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (userId: string) => {
|
const handleRemoveMember = async (userId: string) => {
|
||||||
// 1. Hiển thị hộp thoại xác nhận bằng SweetAlert2
|
|
||||||
const result = await Swal.fire({
|
const result = await Swal.fire({
|
||||||
title: "Xác nhận xóa?",
|
title: "Xác nhận xóa?",
|
||||||
text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?",
|
text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?",
|
||||||
@@ -263,18 +262,18 @@ export default function ProjectDetailsPage() {
|
|||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: "overview",
|
||||||
label: "Overview",
|
label: "Tổng quan",
|
||||||
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "members",
|
id: "members",
|
||||||
label: `Members`,
|
label: `Thành viên`,
|
||||||
count: project.members?.length || 0,
|
count: project.members?.length || 0,
|
||||||
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
label: "Settings",
|
label: "Cài đặt",
|
||||||
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
||||||
},
|
},
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
@@ -324,7 +323,7 @@ export default function ProjectDetailsPage() {
|
|||||||
<div className="md:col-span-3">
|
<div className="md:col-span-3">
|
||||||
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
||||||
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||||
About
|
Thông tin
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
|
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
|
||||||
{project.description || (
|
{project.description || (
|
||||||
@@ -339,7 +338,7 @@ export default function ProjectDetailsPage() {
|
|||||||
<div className="md:col-span-1 space-y-6">
|
<div className="md:col-span-1 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
|
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Owner
|
Chủ dự án
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||||
@@ -379,7 +378,7 @@ export default function ProjectDetailsPage() {
|
|||||||
{activeTab === "members" && (
|
{activeTab === "members" && (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||||
Manage access
|
Quản lý quyền truy cập
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -403,14 +402,14 @@ export default function ProjectDetailsPage() {
|
|||||||
}
|
}
|
||||||
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="EDITOR">Editor</option>
|
<option value="EDITOR">Chỉnh sửa</option>
|
||||||
<option value="VIEWER">Viewer</option>
|
<option value="VIEWER">Xem</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
|
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
|
||||||
>
|
>
|
||||||
Add member
|
Thêm thành viên
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -449,8 +448,8 @@ export default function ProjectDetailsPage() {
|
|||||||
}
|
}
|
||||||
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
|
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<option value="EDITOR">Editor</option>
|
<option value="EDITOR">Chỉnh sửa</option>
|
||||||
<option value="VIEWER">Viewer</option>
|
<option value="VIEWER">Xem</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveMember(member.user_id)}
|
onClick={() => handleRemoveMember(member.user_id)}
|
||||||
@@ -488,12 +487,12 @@ export default function ProjectDetailsPage() {
|
|||||||
<div className="max-w-3xl space-y-10">
|
<div className="max-w-3xl space-y-10">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||||
General
|
Chung
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={handleUpdateInfo} className="space-y-5">
|
<form onSubmit={handleUpdateInfo} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Project name
|
Tên dự án
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -506,7 +505,7 @@ export default function ProjectDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Description
|
Mô tả
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -519,7 +518,7 @@ export default function ProjectDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Status
|
Nhãn
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={editForm.status}
|
value={editForm.status}
|
||||||
@@ -531,32 +530,32 @@ export default function ProjectDetailsPage() {
|
|||||||
}
|
}
|
||||||
className="w-48 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
className="w-48 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="PUBLIC">Public</option>
|
<option value="PUBLIC">Công khai</option>
|
||||||
<option value="PRIVATE">Private</option>
|
<option value="PRIVATE">Riêng tư</option>
|
||||||
<option value="ARCHIVE">Archive</option>
|
<option value="ARCHIVE">Lưu trữ</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
|
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
|
||||||
>
|
>
|
||||||
Update settings
|
Cập nhật
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
|
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
|
||||||
Danger Zone
|
Vùng nguy hiểm
|
||||||
</h2>
|
</h2>
|
||||||
<div className="border border-red-500/30 rounded-xl overflow-hidden">
|
<div className="border border-red-500/30 rounded-xl overflow-hidden">
|
||||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Transfer ownership
|
Chuyển quyền sở hữu
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||||
Transfer this project to another member in the project.
|
Chuyển quyền sở hữu dự án này sang một thành viên khác trong dự án.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@@ -593,18 +592,17 @@ export default function ProjectDetailsPage() {
|
|||||||
}
|
}
|
||||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
|
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
Transfer
|
Chuyển quyền
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
|
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||||
Delete this project
|
Xóa dự án
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||||
Once you delete a project, there is no going back. Please
|
Thận trọng với hành động này! Xóa dự án sẽ xóa vĩnh viễn tất cả dữ liệu liên quan và không thể khôi phục.
|
||||||
be certain.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -612,7 +610,7 @@ export default function ProjectDetailsPage() {
|
|||||||
onClick={handleDeleteProject}
|
onClick={handleDeleteProject}
|
||||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
|
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
|
||||||
>
|
>
|
||||||
Delete project
|
Xóa dự án
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useModal } from "@/hooks/useModal";
|
import { useModal } from "@/hooks/useModal";
|
||||||
@@ -11,19 +10,42 @@ import { Modal } from "@/components/ui/modal";
|
|||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/components/ui/button/Button";
|
||||||
import Label from "@/components/form/Label";
|
import Label from "@/components/form/Label";
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
import { CreateProjectPayload, Project, ProjectMember } from "@/interface/project";
|
||||||
import {
|
import {
|
||||||
apiCreateProject,
|
apiCreateProject,
|
||||||
apiCreateProjectCommit,
|
apiCreateProjectCommit,
|
||||||
apiGetProjectCommits,
|
apiGetProjectCommits,
|
||||||
getCurrentProject,
|
getCurrentProject,
|
||||||
} from "@/service/projectService";
|
} from "@/service/projectService";
|
||||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
import type { EditorSnapshot, ProjectCommit } from "@/uhm/types/projects";
|
||||||
import StickyHeader from "@/components/ui/StickyHeader";
|
import StickyHeader from "@/components/ui/StickyHeader";
|
||||||
|
|
||||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||||
|
|
||||||
|
const IMPORT_JSON_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const IMPORT_JSON_MAX_LABEL = "2MB";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProjectCommitList(value: unknown): ProjectCommit[] {
|
||||||
|
let rows: unknown[] = [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
rows = value;
|
||||||
|
} else if (isRecord(value)) {
|
||||||
|
if (Array.isArray(value.items)) {
|
||||||
|
rows = value.items;
|
||||||
|
} else if (Array.isArray(value.data)) {
|
||||||
|
rows = value.data;
|
||||||
|
} else if (isRecord(value.data) && Array.isArray(value.data.items)) {
|
||||||
|
rows = value.data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.filter((row): row is ProjectCommit => isRecord(row) && typeof row.id === "string");
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@@ -101,10 +123,11 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
|
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
|
||||||
if (importSnapshot) {
|
if (importSnapshot) {
|
||||||
|
const snapshot = toApiEditorSnapshot(importSnapshot);
|
||||||
await apiCreateProjectCommit(projectId, {
|
await apiCreateProjectCommit(projectId, {
|
||||||
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
||||||
snapshot_json: importSnapshot as any,
|
snapshot_json: snapshot,
|
||||||
} as any);
|
});
|
||||||
toast.success("Tạo dự án từ JSON thành công!");
|
toast.success("Tạo dự án từ JSON thành công!");
|
||||||
} else {
|
} else {
|
||||||
toast.success("Tạo dự án mới thành công!");
|
toast.success("Tạo dự án mới thành công!");
|
||||||
@@ -138,11 +161,10 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
setIsExportingProjectId(projectId);
|
setIsExportingProjectId(projectId);
|
||||||
try {
|
try {
|
||||||
const res: any = await apiGetProjectCommits(projectId);
|
const res = await apiGetProjectCommits(projectId);
|
||||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
const commits = extractProjectCommitList(res);
|
||||||
const commits = Array.isArray(rawList) ? rawList : [];
|
|
||||||
const head =
|
const head =
|
||||||
commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
commits.find((c) => String(c.id || "") === headCommitId) || null;
|
||||||
const snapshot = head?.snapshot_json ?? null;
|
const snapshot = head?.snapshot_json ?? null;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||||
@@ -174,6 +196,14 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
const handleImportJsonFile = async (file: File | null) => {
|
const handleImportJsonFile = async (file: File | null) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > IMPORT_JSON_MAX_BYTES) {
|
||||||
|
setImportSnapshot(null);
|
||||||
|
setImportSnapshotName(null);
|
||||||
|
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||||
|
toast.error(`File JSON tối đa ${IMPORT_JSON_MAX_LABEL}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const raw = JSON.parse(text) as unknown;
|
const raw = JSON.parse(text) as unknown;
|
||||||
@@ -200,12 +230,9 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedProjects = [...projects].sort((a: any, b: any) => {
|
const sortedProjects = [...projects].sort((a, b) => {
|
||||||
let valA = a[sortBy];
|
const valA = String(a[sortBy] || "");
|
||||||
let valB = b[sortBy];
|
const valB = String(b[sortBy] || "");
|
||||||
|
|
||||||
if (!valA) valA = "";
|
|
||||||
if (!valB) valB = "";
|
|
||||||
|
|
||||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||||
@@ -331,7 +358,7 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
{sortedProjects.map((project: any) => (
|
{sortedProjects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
||||||
@@ -383,7 +410,7 @@ export default function ProjectsPage() {
|
|||||||
<>
|
<>
|
||||||
{project.members
|
{project.members
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((m: any, index: number) =>
|
.map((m: ProjectMember, index: number) =>
|
||||||
m.avatar_url ? (
|
m.avatar_url ? (
|
||||||
<Image
|
<Image
|
||||||
key={index}
|
key={index}
|
||||||
@@ -575,6 +602,9 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Khởi tạo từ JSON</Label>
|
<Label>Khởi tạo từ JSON</Label>
|
||||||
|
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Chỉ hỗ trợ JSON snapshot tối đa {IMPORT_JSON_MAX_LABEL}.
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -591,7 +621,7 @@ export default function ProjectsPage() {
|
|||||||
<input
|
<input
|
||||||
ref={importJsonInputRef}
|
ref={importJsonInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json"
|
accept="application/json,.json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleImportJsonFile(e.target.files?.[0] || null)
|
handleImportJsonFile(e.target.files?.[0] || null)
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -485,7 +485,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
|
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
|
||||||
return { ...r, content: html };
|
return { ...r, content: html };
|
||||||
});
|
});
|
||||||
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
setComparisonData(processedResults.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||||
setViewMode("compare");
|
setViewMode("compare");
|
||||||
} catch (err) {
|
} 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.";
|
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
|
||||||
|
|||||||
@@ -2,38 +2,14 @@ export type StoredTokens = {
|
|||||||
access_token: string;
|
access_token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LS_KEY = "uhm_auth_tokens_v1";
|
|
||||||
|
|
||||||
let cached: StoredTokens | null = null;
|
let cached: StoredTokens | null = null;
|
||||||
|
|
||||||
function safeParseTokens(raw: string | null): StoredTokens | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(raw) as Partial<StoredTokens>;
|
|
||||||
if (!v || typeof v !== "object") return null;
|
|
||||||
if (typeof v.access_token !== "string") return null;
|
|
||||||
if (!v.access_token.trim()) return null;
|
|
||||||
return { access_token: v.access_token };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredTokens(): StoredTokens | null {
|
export function getStoredTokens(): StoredTokens | null {
|
||||||
if (cached) return cached;
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
cached = safeParseTokens(window.localStorage.getItem(LS_KEY));
|
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setStoredTokens(tokens: StoredTokens | null): void {
|
export function setStoredTokens(tokens: StoredTokens | null): void {
|
||||||
cached = tokens;
|
cached = tokens;
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (!tokens) {
|
|
||||||
window.localStorage.removeItem(LS_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.localStorage.setItem(LS_KEY, JSON.stringify(tokens));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
@@ -47,11 +23,6 @@ export function clearStoredTokens(): void {
|
|||||||
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
|
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
|
||||||
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
|
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
|
||||||
const data = payload?.data ?? payload;
|
const data = payload?.data ?? payload;
|
||||||
// Common shapes observed in various backends:
|
|
||||||
// - { status: true, data: { access_token, refresh_token } }
|
|
||||||
// - { data: { tokens: { access_token, refresh_token } } }
|
|
||||||
// - { data: { token: <access>, refresh_token } }
|
|
||||||
// - { accessToken, refreshToken }
|
|
||||||
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
|
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
|
||||||
|
|
||||||
const access =
|
const access =
|
||||||
@@ -62,11 +33,6 @@ export function extractTokensFromResponsePayload(payload: any): StoredTokens | n
|
|||||||
tokenContainer?.jwt ??
|
tokenContainer?.jwt ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
const refresh =
|
|
||||||
tokenContainer?.refresh_token ??
|
|
||||||
tokenContainer?.refreshToken ??
|
|
||||||
tokenContainer?.refresh ??
|
|
||||||
null;
|
|
||||||
if (typeof access === "string" && access.trim()) {
|
if (typeof access === "string" && access.trim()) {
|
||||||
return { access_token: access };
|
return { access_token: access };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import FullCalendar from "@fullcalendar/react";
|
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
|
||||||
import {
|
|
||||||
EventInput,
|
|
||||||
DateSelectArg,
|
|
||||||
EventClickArg,
|
|
||||||
EventContentArg,
|
|
||||||
} from "@fullcalendar/core";
|
|
||||||
import { useModal } from "@/hooks/useModal";
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
|
||||||
import { newId } from "@/uhm/lib/utils/id";
|
|
||||||
|
|
||||||
interface CalendarEvent extends EventInput {
|
|
||||||
extendedProps: {
|
|
||||||
calendar: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const Calendar: React.FC = () => {
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [eventTitle, setEventTitle] = useState("");
|
|
||||||
const [eventStartDate, setEventStartDate] = useState("");
|
|
||||||
const [eventEndDate, setEventEndDate] = useState("");
|
|
||||||
const [eventLevel, setEventLevel] = useState("");
|
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
|
||||||
const calendarRef = useRef<FullCalendar>(null);
|
|
||||||
const { isOpen, openModal, closeModal } = useModal();
|
|
||||||
|
|
||||||
const calendarsEvents = {
|
|
||||||
Danger: "danger",
|
|
||||||
Success: "success",
|
|
||||||
Primary: "primary",
|
|
||||||
Warning: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize with some events
|
|
||||||
setEvents([
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
title: "Event Conf.",
|
|
||||||
start: new Date().toISOString().split("T")[0],
|
|
||||||
extendedProps: { calendar: "Danger" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
title: "Meeting",
|
|
||||||
start: new Date(Date.now() + 86400000).toISOString().split("T")[0],
|
|
||||||
extendedProps: { calendar: "Success" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
title: "Workshop",
|
|
||||||
start: new Date(Date.now() + 172800000).toISOString().split("T")[0],
|
|
||||||
end: new Date(Date.now() + 259200000).toISOString().split("T")[0],
|
|
||||||
extendedProps: { calendar: "Primary" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
|
||||||
resetModalFields();
|
|
||||||
setEventStartDate(selectInfo.startStr);
|
|
||||||
setEventEndDate(selectInfo.endStr || selectInfo.startStr);
|
|
||||||
openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEventClick = (clickInfo: EventClickArg) => {
|
|
||||||
const event = clickInfo.event;
|
|
||||||
setSelectedEvent({
|
|
||||||
id: event.id,
|
|
||||||
title: event.title,
|
|
||||||
start: event.startStr,
|
|
||||||
end: event.endStr,
|
|
||||||
extendedProps: {
|
|
||||||
calendar: event.extendedProps.calendar,
|
|
||||||
},
|
|
||||||
} as CalendarEvent);
|
|
||||||
|
|
||||||
setEventTitle(event.title);
|
|
||||||
setEventStartDate(event.start?.toISOString().split("T")[0] || "");
|
|
||||||
setEventEndDate(event.end?.toISOString().split("T")[0] || "");
|
|
||||||
setEventLevel(event.extendedProps.calendar);
|
|
||||||
openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddOrUpdateEvent = () => {
|
|
||||||
if (selectedEvent) {
|
|
||||||
// Update existing event
|
|
||||||
setEvents((prevEvents) =>
|
|
||||||
prevEvents.map((event) =>
|
|
||||||
event.id === selectedEvent.id
|
|
||||||
? {
|
|
||||||
...event,
|
|
||||||
title: eventTitle,
|
|
||||||
start: eventStartDate,
|
|
||||||
end: eventEndDate,
|
|
||||||
extendedProps: { calendar: eventLevel },
|
|
||||||
}
|
|
||||||
: event
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Add new event
|
|
||||||
const newEvent: CalendarEvent = {
|
|
||||||
id: newId(),
|
|
||||||
title: eventTitle,
|
|
||||||
start: eventStartDate,
|
|
||||||
end: eventEndDate,
|
|
||||||
allDay: true,
|
|
||||||
extendedProps: { calendar: eventLevel },
|
|
||||||
};
|
|
||||||
setEvents((prevEvents) => [...prevEvents, newEvent]);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
resetModalFields();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetModalFields = () => {
|
|
||||||
setEventTitle("");
|
|
||||||
setEventStartDate("");
|
|
||||||
setEventEndDate("");
|
|
||||||
setEventLevel("");
|
|
||||||
setSelectedEvent(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
|
|
||||||
<div className="custom-calendar">
|
|
||||||
<FullCalendar
|
|
||||||
ref={calendarRef}
|
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
||||||
initialView="dayGridMonth"
|
|
||||||
headerToolbar={{
|
|
||||||
left: "prev,next addEventButton",
|
|
||||||
center: "title",
|
|
||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
|
||||||
}}
|
|
||||||
events={events}
|
|
||||||
selectable={true}
|
|
||||||
select={handleDateSelect}
|
|
||||||
eventClick={handleEventClick}
|
|
||||||
eventContent={renderEventContent}
|
|
||||||
customButtons={{
|
|
||||||
addEventButton: {
|
|
||||||
text: "Add Event +",
|
|
||||||
click: openModal,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={closeModal}
|
|
||||||
className="max-w-[700px] p-6 lg:p-10"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col px-2 overflow-y-auto custom-scrollbar">
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-2 font-semibold text-gray-800 modal-title text-theme-xl dark:text-white/90 lg:text-2xl">
|
|
||||||
{selectedEvent ? "Edit Event" : "Add Event"}
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Plan your next big moment: schedule or edit an event to stay on
|
|
||||||
track
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
|
||||||
Event Title
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="event-title"
|
|
||||||
type="text"
|
|
||||||
value={eventTitle}
|
|
||||||
onChange={(e) => setEventTitle(e.target.value)}
|
|
||||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="block mb-4 text-sm font-medium text-gray-700 dark:text-gray-400">
|
|
||||||
Event Color
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap items-center gap-4 sm:gap-5">
|
|
||||||
{Object.entries(calendarsEvents).map(([key, value]) => (
|
|
||||||
<div key={key} className="n-chk">
|
|
||||||
<div
|
|
||||||
className={`form-check form-check-${value} form-check-inline`}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="flex items-center text-sm text-gray-700 form-check-label dark:text-gray-400"
|
|
||||||
htmlFor={`modal${key}`}
|
|
||||||
>
|
|
||||||
<span className="relative">
|
|
||||||
<input
|
|
||||||
className="sr-only form-check-input"
|
|
||||||
type="radio"
|
|
||||||
name="event-level"
|
|
||||||
value={key}
|
|
||||||
id={`modal${key}`}
|
|
||||||
checked={eventLevel === key}
|
|
||||||
onChange={() => setEventLevel(key)}
|
|
||||||
/>
|
|
||||||
<span className="flex items-center justify-center w-5 h-5 mr-2 border border-gray-300 rounded-full box dark:border-gray-700">
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 rounded-full bg-white ${
|
|
||||||
eventLevel === key ? "block" : "hidden"
|
|
||||||
}`}
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{key}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
|
||||||
Enter Start Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="event-start-date"
|
|
||||||
type="date"
|
|
||||||
value={eventStartDate}
|
|
||||||
onChange={(e) => setEventStartDate(e.target.value)}
|
|
||||||
className="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 pl-4 pr-11 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
|
||||||
Enter End Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="event-end-date"
|
|
||||||
type="date"
|
|
||||||
value={eventEndDate}
|
|
||||||
onChange={(e) => setEventEndDate(e.target.value)}
|
|
||||||
className="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 pl-4 pr-11 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-6 modal-footer sm:justify-end">
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full justify-center rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] sm:w-auto"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAddOrUpdateEvent}
|
|
||||||
type="button"
|
|
||||||
className="btn btn-success btn-update-event flex w-full justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-600 sm:w-auto"
|
|
||||||
>
|
|
||||||
{selectedEvent ? "Update Changes" : "Add Event"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEventContent = (eventInfo: EventContentArg) => {
|
|
||||||
const colorClass = `fc-bg-${eventInfo.event.extendedProps.calendar.toLowerCase()}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`event-fc-color flex fc-event-main ${colorClass} p-1 rounded-sm`}
|
|
||||||
>
|
|
||||||
<div className="fc-daygrid-event-dot"></div>
|
|
||||||
<div className="fc-event-time">{eventInfo.timeText}</div>
|
|
||||||
<div className="fc-event-title">{eventInfo.event.title}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Calendar;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { ApexOptions } from "apexcharts";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
// Dynamically import the ReactApexChart component
|
|
||||||
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function BarChartOne() {
|
|
||||||
const options: ApexOptions = {
|
|
||||||
colors: ["#465fff"],
|
|
||||||
chart: {
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
type: "bar",
|
|
||||||
height: 180,
|
|
||||||
toolbar: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plotOptions: {
|
|
||||||
bar: {
|
|
||||||
horizontal: false,
|
|
||||||
columnWidth: "39%",
|
|
||||||
borderRadius: 5,
|
|
||||||
borderRadiusApplication: "end",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataLabels: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
show: true,
|
|
||||||
width: 4,
|
|
||||||
colors: ["transparent"],
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
categories: [
|
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec",
|
|
||||||
],
|
|
||||||
axisBorder: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
axisTicks: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
position: "top",
|
|
||||||
horizontalAlign: "left",
|
|
||||||
fontFamily: "Outfit",
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
title: {
|
|
||||||
text: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
yaxis: {
|
|
||||||
lines: {
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
tooltip: {
|
|
||||||
x: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
formatter: (val: number) => `${val}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const series = [
|
|
||||||
{
|
|
||||||
name: "Sales",
|
|
||||||
data: [168, 385, 201, 298, 187, 195, 291, 110, 215, 390, 280, 112],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
|
||||||
<div id="chartOne" className="min-w-[1000px]">
|
|
||||||
<ReactApexChart
|
|
||||||
options={options}
|
|
||||||
series={series}
|
|
||||||
type="bar"
|
|
||||||
height={180}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { ApexOptions } from "apexcharts";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
// Dynamically import the ReactApexChart component
|
|
||||||
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function LineChartOne() {
|
|
||||||
const options: ApexOptions = {
|
|
||||||
legend: {
|
|
||||||
show: false, // Hide legend
|
|
||||||
position: "top",
|
|
||||||
horizontalAlign: "left",
|
|
||||||
},
|
|
||||||
colors: ["#465FFF", "#9CB9FF"], // Define line colors
|
|
||||||
chart: {
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
height: 310,
|
|
||||||
type: "line", // Set the chart type to 'line'
|
|
||||||
toolbar: {
|
|
||||||
show: false, // Hide chart toolbar
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
curve: "straight", // Define the line style (straight, smooth, or step)
|
|
||||||
width: [2, 2], // Line width for each dataset
|
|
||||||
},
|
|
||||||
|
|
||||||
fill: {
|
|
||||||
type: "gradient",
|
|
||||||
gradient: {
|
|
||||||
opacityFrom: 0.55,
|
|
||||||
opacityTo: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
markers: {
|
|
||||||
size: 0, // Size of the marker points
|
|
||||||
strokeColors: "#fff", // Marker border color
|
|
||||||
strokeWidth: 2,
|
|
||||||
hover: {
|
|
||||||
size: 6, // Marker size on hover
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
xaxis: {
|
|
||||||
lines: {
|
|
||||||
show: false, // Hide grid lines on x-axis
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
lines: {
|
|
||||||
show: true, // Show grid lines on y-axis
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataLabels: {
|
|
||||||
enabled: false, // Disable data labels
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: true, // Enable tooltip
|
|
||||||
x: {
|
|
||||||
format: "dd MMM yyyy", // Format for x-axis tooltip
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
type: "category", // Category-based x-axis
|
|
||||||
categories: [
|
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec",
|
|
||||||
],
|
|
||||||
axisBorder: {
|
|
||||||
show: false, // Hide x-axis border
|
|
||||||
},
|
|
||||||
axisTicks: {
|
|
||||||
show: false, // Hide x-axis ticks
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: false, // Disable tooltip for x-axis points
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: "12px", // Adjust font size for y-axis labels
|
|
||||||
colors: ["#6B7280"], // Color of the labels
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
text: "", // Remove y-axis title
|
|
||||||
style: {
|
|
||||||
fontSize: "0px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const series = [
|
|
||||||
{
|
|
||||||
name: "Sales",
|
|
||||||
data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Revenue",
|
|
||||||
data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
|
||||||
<div id="chartEight" className="min-w-[1000px]">
|
|
||||||
<ReactApexChart
|
|
||||||
options={options}
|
|
||||||
series={series}
|
|
||||||
type="area"
|
|
||||||
height={310}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
const ChartTab: React.FC = () => {
|
|
||||||
const [selected, setSelected] = useState<
|
|
||||||
"optionOne" | "optionTwo" | "optionThree"
|
|
||||||
>("optionOne");
|
|
||||||
|
|
||||||
const getButtonClass = (option: "optionOne" | "optionTwo" | "optionThree") =>
|
|
||||||
selected === option
|
|
||||||
? "shadow-theme-xs text-gray-900 dark:text-white bg-white dark:bg-gray-800"
|
|
||||||
: "text-gray-500 dark:text-gray-400";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelected("optionOne")}
|
|
||||||
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
|
|
||||||
"optionOne"
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setSelected("optionTwo")}
|
|
||||||
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
|
|
||||||
"optionTwo"
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
Quarterly
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setSelected("optionThree")}
|
|
||||||
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
|
|
||||||
"optionThree"
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
Annually
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChartTab;
|
|
||||||