feat: implement event submission flow with image upload and modal interface
This commit is contained in:
631
admin/package-lock.json
generated
631
admin/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"afrohub": "file:..",
|
"afrohub": "file:..",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
@@ -22,14 +23,20 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-quill-new": "^3.8.3"
|
"react-quill-new": "^3.8.3",
|
||||||
|
"react-simple-maps": "^3.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"topojson-client": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -1379,6 +1386,42 @@
|
|||||||
"@prisma/debug": "6.19.3"
|
"@prisma/debug": "6.19.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1392,6 +1435,12 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1689,6 +1738,98 @@
|
|||||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-geo": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "^2",
|
||||||
|
"@types/d3-selection": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1696,6 +1837,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -1752,6 +1900,46 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-simple-maps": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-geo": "^2",
|
||||||
|
"@types/d3-zoom": "^2",
|
||||||
|
"@types/geojson": "*",
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/topojson-client": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*",
|
||||||
|
"@types/topojson-specification": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/topojson-specification": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.58.1",
|
"version": "8.58.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||||
@@ -2859,6 +3047,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2879,6 +3076,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2929,6 +3132,226 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 2",
|
||||||
|
"d3-selection": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "^2.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo/node_modules/d3-array": {
|
||||||
|
"version": "2.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
|
||||||
|
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo/node_modules/internmap": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 2",
|
||||||
|
"d3-dispatch": "1 - 2",
|
||||||
|
"d3-ease": "1 - 2",
|
||||||
|
"d3-interpolate": "1 - 2",
|
||||||
|
"d3-timer": "1 - 2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition/node_modules/d3-color": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition/node_modules/d3-interpolate": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 2",
|
||||||
|
"d3-drag": "2",
|
||||||
|
"d3-interpolate": "1 - 2",
|
||||||
|
"d3-selection": "2",
|
||||||
|
"d3-transition": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom/node_modules/d3-color": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom/node_modules/d3-interpolate": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -3008,6 +3431,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3346,6 +3775,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.46.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
||||||
|
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -4297,6 +4736,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4339,6 +4788,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6162,6 +6620,46 @@
|
|||||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-simple-maps": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-geo": "^2.0.2",
|
||||||
|
"d3-selection": "^2.0.0",
|
||||||
|
"d3-zoom": "^2.0.0",
|
||||||
|
"topojson-client": "^3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react": "^16.8.0 || 17.x || 18.x",
|
||||||
|
"react-dom": "^16.8.0 || 17.x || 18.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -6175,6 +6673,51 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6219,6 +6762,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
@@ -6820,6 +7369,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
||||||
@@ -6890,6 +7445,20 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/topojson-client": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"topo2geo": "bin/topo2geo",
|
||||||
|
"topomerge": "bin/topomerge",
|
||||||
|
"topoquantize": "bin/topoquantize"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -7030,7 +7599,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -7166,6 +7735,64 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor/node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor/node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor/node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"afrohub": "file:..",
|
"afrohub": "file:..",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
@@ -23,14 +24,20 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-quill-new": "^3.8.3"
|
"react-quill-new": "^3.8.3",
|
||||||
|
"react-simple-maps": "^3.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"topojson-client": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ model BlogPost {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model HomeSlide {
|
model HomeSlide {
|
||||||
@@ -174,6 +176,8 @@ model HomeSlide {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
business Business? @relation(fields: [businessId], references: [id])
|
business Business? @relation(fields: [businessId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +202,8 @@ model Interview {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -358,6 +364,8 @@ model Event {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Favorite {
|
model Favorite {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function createBlogPost(data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -51,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export async function createEvent(data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -53,6 +55,8 @@ export async function updateEvent(id: string, data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export async function createSlide(data: any) {
|
|||||||
linkUrl: data.linkUrl || null,
|
linkUrl: data.linkUrl || null,
|
||||||
order: data.order || 0,
|
order: data.order || 0,
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
|
coverPosition: data.coverPosition || "50% 50%",
|
||||||
|
coverZoom: data.coverZoom || 1,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
revalidatePath("/slides");
|
revalidatePath("/slides");
|
||||||
@@ -50,6 +52,8 @@ export async function updateSlide(id: string, data: any) {
|
|||||||
linkUrl: data.linkUrl || null,
|
linkUrl: data.linkUrl || null,
|
||||||
order: data.order || 0,
|
order: data.order || 0,
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
|
coverPosition: data.coverPosition || "50% 50%",
|
||||||
|
coverZoom: data.coverZoom || 1,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
revalidatePath("/slides");
|
revalidatePath("/slides");
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
|
|||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
// Create a unique filename
|
// Create a unique filename
|
||||||
const fileExtension = file.name.split('.').pop();
|
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||||
|
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
||||||
|
|
||||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||||
|
|
||||||
// Path for the ROOT public/uploads (where the main site will look)
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
|
|||||||
@@ -299,3 +299,23 @@ body {
|
|||||||
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
|
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles pour les tableaux dans l'éditeur */
|
||||||
|
.quill-dark-wrapper .ql-editor table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor th,
|
||||||
|
.quill-dark-wrapper .ql-editor td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor th {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Map as MapIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import AnalyticsMap from '@/components/AnalyticsMap';
|
||||||
|
import CountryHistogram from '@/components/CountryHistogram';
|
||||||
|
|
||||||
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
||||||
if (value === null) return null;
|
if (value === null) return null;
|
||||||
@@ -76,12 +79,28 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
|
|
||||||
const topCountries = await prisma.analyticsEvent.groupBy({
|
const topCountries = await prisma.analyticsEvent.groupBy({
|
||||||
by: ['country'],
|
by: ['country'],
|
||||||
where: currentWhere,
|
where: { type: 'PAGE_VIEW', ...currentWhere },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
orderBy: { _count: { id: 'desc' } },
|
orderBy: { _count: { id: 'desc' } },
|
||||||
take: 10
|
take: 50 // Increased to get more map data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unique visitors per country
|
||||||
|
const uniqueVisitorsPerCountryRaw = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['country', 'ip'],
|
||||||
|
where: { type: 'PAGE_VIEW', ...currentWhere }
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueVisitorsPerCountryMap: Record<string, number> = {};
|
||||||
|
uniqueVisitorsPerCountryRaw.forEach(item => {
|
||||||
|
const country = item.country || 'Unknown';
|
||||||
|
uniqueVisitorsPerCountryMap[country] = (uniqueVisitorsPerCountryMap[country] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueVisitorsPerCountry = Object.entries(uniqueVisitorsPerCountryMap)
|
||||||
|
.map(([country, count]) => ({ country, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
const devices = await prisma.analyticsEvent.groupBy({
|
const devices = await prisma.analyticsEvent.groupBy({
|
||||||
by: ['device'],
|
by: ['device'],
|
||||||
where: currentWhere,
|
where: currentWhere,
|
||||||
@@ -156,6 +175,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
totalEvents,
|
totalEvents,
|
||||||
uniqueVisitors,
|
uniqueVisitors,
|
||||||
recentIPs,
|
recentIPs,
|
||||||
|
uniqueVisitorsPerCountry,
|
||||||
deltas: {
|
deltas: {
|
||||||
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
||||||
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
||||||
@@ -169,7 +189,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
||||||
const searchParams = await searchParamsPromise;
|
const searchParams = await searchParamsPromise;
|
||||||
const range = searchParams.range || 'week';
|
const range = searchParams.range || 'week';
|
||||||
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
|
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||||
|
|
||||||
const rangeLabels: Record<string, string> = {
|
const rangeLabels: Record<string, string> = {
|
||||||
day: 'Dernières 24h',
|
day: 'Dernières 24h',
|
||||||
@@ -237,13 +257,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<Users className="w-6 h-6" />
|
<Users className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
|
<div className="text-2xl font-bold text-white">{uniqueVisitors.toLocaleString()}</div>
|
||||||
<ComparisonBadge value={deltas.visitors} />
|
<ComparisonBadge value={deltas.visitors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Personnes réelles distinctes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,13 +273,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<Eye className="w-6 h-6" />
|
<Eye className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-white">{totalEvents}</div>
|
<div className="text-2xl font-bold text-white">{totalEvents.toLocaleString()}</div>
|
||||||
<ComparisonBadge value={deltas.events} />
|
<ComparisonBadge value={deltas.events} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Impressions (Pages vues)</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Nombre total de fois où les pages ont été vues
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,13 +295,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<ComparisonBadge value={deltas.mobile} />
|
<ComparisonBadge value={deltas.mobile} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Trafic Mobile</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Part des appareils mobiles
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* World Map Section */}
|
||||||
|
<AnalyticsMap
|
||||||
|
data={uniqueVisitorsPerCountry}
|
||||||
|
title="Répartition Géographique (Visiteurs Uniques)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Histogram Section */}
|
||||||
|
<CountryHistogram
|
||||||
|
title="Comparatif Impressions vs Visiteurs Uniques"
|
||||||
|
data={topCountries.map(c => ({
|
||||||
|
country: c.country || 'Inconnu',
|
||||||
|
impressions: c._count.id,
|
||||||
|
visitors: uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Top Countries Table */}
|
{/* Top Countries Table */}
|
||||||
<div className="card p-0 overflow-hidden">
|
<div className="card p-0 overflow-hidden">
|
||||||
@@ -294,18 +330,28 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Pays</th>
|
<th className="px-6 py-4">Pays</th>
|
||||||
<th className="px-6 py-4 text-right">Visites</th>
|
<th className="px-6 py-4 text-right">Impressions</th>
|
||||||
|
<th className="px-6 py-4 text-right">V. Uniques</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{topCountries.map((c) => (
|
{topCountries.map((c) => {
|
||||||
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
|
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
|
||||||
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
|
return (
|
||||||
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
|
||||||
{c._count.id}
|
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
|
||||||
</td>
|
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
|
||||||
</tr>
|
{c.country || 'Inconnu'}
|
||||||
))}
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-medium text-slate-400">
|
||||||
|
{c._count.id.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
||||||
|
{uniqueCount.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useTransition, useState, useEffect } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
|
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
|
||||||
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
@@ -26,6 +26,9 @@ interface Props {
|
|||||||
metaDescription?: string | null;
|
metaDescription?: string | null;
|
||||||
publishedAt?: Date | string | null;
|
publishedAt?: Date | string | null;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string | null;
|
||||||
|
coverZoom?: number | null;
|
||||||
|
sources?: any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
const [publishedAtValue, setPublishedAtValue] = useState(
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
@@ -41,6 +46,9 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
: new Date().toISOString().slice(0, 16)
|
: new Date().toISOString().slice(0, 16)
|
||||||
);
|
);
|
||||||
|
const [sources, setSources] = useState<{ title: string; url: string }[]>(
|
||||||
|
Array.isArray(initialData?.sources) ? initialData.sources : []
|
||||||
|
);
|
||||||
|
|
||||||
const isPublished = new Date(publishedAtValue) <= new Date();
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
@@ -65,6 +73,9 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
status: formData.get('status') as ContentStatus,
|
status: formData.get('status') as ContentStatus,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
|
sources: sources,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.imageUrl) {
|
if (!data.imageUrl) {
|
||||||
@@ -148,6 +159,11 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
value={imageUrl}
|
value={imageUrl}
|
||||||
onChange={setImageUrl}
|
onChange={setImageUrl}
|
||||||
name="imageUrl"
|
name="imageUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -187,6 +203,61 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
placeholder="Rédigez votre article ici..."
|
placeholder="Rédigez votre article ici..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SOURCES SECTION */}
|
||||||
|
<div className="pt-6 border-t border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
||||||
|
<LinkIcon className="w-4 h-4 text-brand-500" />
|
||||||
|
Sources & Liens externes
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSources([...sources, { title: '', url: '' }])}
|
||||||
|
className="text-xs font-bold text-brand-500 hover:text-brand-400 flex items-center gap-1 bg-brand-500/10 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Ajouter une source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sources.map((source, index) => (
|
||||||
|
<div key={index} className="flex gap-3 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<input
|
||||||
|
placeholder="Nom de la source (ex: Le Monde)"
|
||||||
|
value={source.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSources = [...sources];
|
||||||
|
newSources[index].title = e.target.value;
|
||||||
|
setSources(newSources);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="URL (ex: https://...)"
|
||||||
|
value={source.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSources = [...sources];
|
||||||
|
newSources[index].url = e.target.value;
|
||||||
|
setSources(newSources);
|
||||||
|
}}
|
||||||
|
className="flex-2 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSources(sources.filter((_, i) => i !== index))}
|
||||||
|
className="p-2.5 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-600 italic">Aucune source ajoutée pour cet article.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SEO SECTION */}
|
{/* SEO SECTION */}
|
||||||
|
|||||||
197
admin/src/components/AnalyticsMap.tsx
Normal file
197
admin/src/components/AnalyticsMap.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ComposableMap,
|
||||||
|
Geographies,
|
||||||
|
Geography,
|
||||||
|
Sphere,
|
||||||
|
Graticule,
|
||||||
|
ZoomableGroup
|
||||||
|
} from "react-simple-maps";
|
||||||
|
import { scaleLinear } from "d3-scale";
|
||||||
|
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
// URL for the world map TopoJSON
|
||||||
|
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||||
|
|
||||||
|
interface AnalyticsMapProps {
|
||||||
|
data: { country: string; count: number }[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple mapping for common countries to ISO Alpha-3 codes used by world-atlas
|
||||||
|
// In a real app, this should be handled at the tracking level or via a robust library
|
||||||
|
const countryToISO: Record<string, string> = {
|
||||||
|
"France": "FRA",
|
||||||
|
"United States": "USA",
|
||||||
|
"Canada": "CAN",
|
||||||
|
"United Kingdom": "GBR",
|
||||||
|
"Germany": "DEU",
|
||||||
|
"Ivory Coast": "CIV",
|
||||||
|
"Côte d'Ivoire": "CIV",
|
||||||
|
"Senegal": "SEN",
|
||||||
|
"Sénégal": "SEN",
|
||||||
|
"Cameroon": "CMR",
|
||||||
|
"Cameroun": "CMR",
|
||||||
|
"Mali": "MLI",
|
||||||
|
"Benin": "BEN",
|
||||||
|
"Bénin": "BEN",
|
||||||
|
"Togo": "TGO",
|
||||||
|
"Gabon": "GAB",
|
||||||
|
"Congo": "COG",
|
||||||
|
"DR Congo": "COD",
|
||||||
|
"Nigeria": "NGA",
|
||||||
|
"Ghana": "GHA",
|
||||||
|
"Morocco": "MAR",
|
||||||
|
"Maroc": "MAR",
|
||||||
|
"Algeria": "DZA",
|
||||||
|
"Algérie": "DZA",
|
||||||
|
"Tunisia": "TUN",
|
||||||
|
"Tunisie": "TUN",
|
||||||
|
"Belgium": "BEL",
|
||||||
|
"Belgique": "BEL",
|
||||||
|
"Switzerland": "CHE",
|
||||||
|
"Suisse": "CHE",
|
||||||
|
"Spain": "ESP",
|
||||||
|
"Espagne": "ESP",
|
||||||
|
"Italy": "ITA",
|
||||||
|
"Italie": "ITA",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AnalyticsMap({ data, title }: AnalyticsMapProps) {
|
||||||
|
const maxCount = useMemo(() => {
|
||||||
|
if (data.length === 0) return 1;
|
||||||
|
return Math.max(...data.map((d) => d.count));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const colorScale = scaleLinear<string>()
|
||||||
|
.domain([0, maxCount])
|
||||||
|
.range(["#1e293b", "#6366f1"]);
|
||||||
|
|
||||||
|
const mappedData = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
data.forEach((d) => {
|
||||||
|
const iso = countryToISO[d.country] || d.country; // Fallback to name if not in map
|
||||||
|
map[iso] = d.count;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const [position, setPosition] = React.useState({ coordinates: [0, 0], zoom: 1 });
|
||||||
|
|
||||||
|
function handleZoomIn() {
|
||||||
|
if (position.zoom >= 4) return;
|
||||||
|
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut() {
|
||||||
|
if (position.zoom <= 1) return;
|
||||||
|
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setPosition({ coordinates: [0, 0], zoom: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMoveEnd(position: { coordinates: [number, number]; zoom: number }) {
|
||||||
|
setPosition(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-0 overflow-hidden bg-slate-900/50 border border-slate-800 relative group">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Zoom Controls */}
|
||||||
|
<div className="flex items-center gap-2 bg-slate-800 p-1.5 rounded-lg border border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
|
||||||
|
title="Zoomer"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
|
||||||
|
title="Dézoomer"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 bg-slate-700 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
|
||||||
|
title="Réinitialiser la vue"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex justify-center bg-slate-950/20 h-[600px]">
|
||||||
|
<ComposableMap
|
||||||
|
projectionConfig={{
|
||||||
|
rotate: [-10, 0, 0],
|
||||||
|
scale: 147
|
||||||
|
}}
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
<ZoomableGroup
|
||||||
|
zoom={position.zoom}
|
||||||
|
center={position.coordinates as [number, number]}
|
||||||
|
onMoveEnd={handleMoveEnd}
|
||||||
|
>
|
||||||
|
<Sphere stroke="#334155" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||||
|
<Graticule stroke="#334155" strokeWidth={0.5} />
|
||||||
|
{data.length > 0 && (
|
||||||
|
<Geographies geography={geoUrl}>
|
||||||
|
{({ geographies }) =>
|
||||||
|
geographies.map((geo) => {
|
||||||
|
const countryName = geo.properties.name;
|
||||||
|
const countryId = geo.id;
|
||||||
|
const countryISO = geo.properties.iso_a3;
|
||||||
|
|
||||||
|
const count = mappedData[countryName] || mappedData[countryISO] || mappedData[countryId] || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Geography
|
||||||
|
key={geo.rsmKey}
|
||||||
|
geography={geo}
|
||||||
|
fill={count > 0 ? colorScale(count) : "#0f172a"}
|
||||||
|
stroke="#1e293b"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
style={{
|
||||||
|
default: { outline: "none" },
|
||||||
|
hover: { fill: "#4f46e5", outline: "none", cursor: "pointer" },
|
||||||
|
pressed: { outline: "none" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Geographies>
|
||||||
|
)}
|
||||||
|
</ZoomableGroup>
|
||||||
|
</ComposableMap>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend / Instructions */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-slate-900/80 backdrop-blur-md border border-slate-700 p-3 rounded-lg text-[10px] text-slate-400 uppercase font-bold tracking-widest pointer-events-none">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-[#0f172a] border border-slate-700"></span>
|
||||||
|
<span>Pas de trafic</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"></span>
|
||||||
|
<span>Trafic identifié</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
admin/src/components/CountryHistogram.tsx
Normal file
97
admin/src/components/CountryHistogram.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface CountryHistogramProps {
|
||||||
|
data: { country: string; impressions: number; visitors: number }[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CountryHistogram({ data, title }: CountryHistogramProps) {
|
||||||
|
// Take top 10 for readability
|
||||||
|
const chartData = data.slice(0, 10).map(item => ({
|
||||||
|
name: item.country || 'Inconnu',
|
||||||
|
"Impressions": item.impressions,
|
||||||
|
"V. Uniques": item.visitors
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-slate-900/50 border border-slate-800 p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="h-[400px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={10}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={10}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
border: '1px solid #1e293b',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
itemStyle={{ padding: '2px 0' }}
|
||||||
|
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
align="right"
|
||||||
|
iconType="circle"
|
||||||
|
wrapperStyle={{ paddingBottom: '20px', fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="Impressions"
|
||||||
|
fill="#6366f1"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
barSize={20}
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="V. Uniques"
|
||||||
|
fill="#10b981"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
barSize={20}
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [description, setDescription] = useState(initialData?.description || '');
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
const [publishedAtValue, setPublishedAtValue] = useState(
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
@@ -53,6 +55,8 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
status: formData.get('status') as ContentStatus,
|
status: formData.get('status') as ContentStatus,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.thumbnailUrl) {
|
if (!data.thumbnailUrl) {
|
||||||
@@ -170,6 +174,11 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
value={thumbnailUrl}
|
value={thumbnailUrl}
|
||||||
onChange={setThumbnailUrl}
|
onChange={setThumbnailUrl}
|
||||||
name="thumbnailUrl"
|
name="thumbnailUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -11,9 +11,25 @@ interface Props {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
showPositionControls?: boolean;
|
||||||
|
position?: string;
|
||||||
|
zoom?: number;
|
||||||
|
onPositionChange?: (value: string) => void;
|
||||||
|
onZoomChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
|
export default function ImageUploader({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
showPositionControls = false,
|
||||||
|
position = "50% 50%",
|
||||||
|
zoom = 1,
|
||||||
|
onPositionChange,
|
||||||
|
onZoomChange
|
||||||
|
}: Props) {
|
||||||
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!file.type.startsWith('image/')) {
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
|
||||||
toast.error("Le fichier doit être une image");
|
if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,37 +137,111 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
<div className="space-y-4">
|
||||||
<img
|
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
||||||
src={value}
|
<img
|
||||||
alt="Prévisualisation"
|
src={value}
|
||||||
className="w-full h-full object-cover"
|
alt="Prévisualisation"
|
||||||
/>
|
className="w-full h-full object-cover"
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
style={{
|
||||||
<button
|
objectPosition: position,
|
||||||
type="button"
|
transform: `scale(${zoom})`,
|
||||||
onClick={() => fileInputRef.current?.click()}
|
transformOrigin: position
|
||||||
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
}}
|
||||||
>
|
/>
|
||||||
<Upload className="w-5 h-5" />
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onClick={clearImage}
|
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
||||||
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
>
|
||||||
>
|
<Upload className="w-5 h-5" />
|
||||||
<X className="w-5 h-5" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Hidden inputs for values to be sent via form if needed */}
|
||||||
|
<input type="hidden" name={name} value={value || ""} />
|
||||||
|
{showPositionControls && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name={`${name}Position`} value={position} />
|
||||||
|
<input type="hidden" name={`${name}Zoom`} value={zoom.toString()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Hidden input for value to be sent via form if needed */}
|
|
||||||
<input type="hidden" name={name} value={value || ""} />
|
{showPositionControls && (
|
||||||
|
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-800 space-y-4 animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Zoom : {zoom.toFixed(1)}x</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onZoomChange?.(1)}
|
||||||
|
className="text-[10px] text-indigo-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => onZoomChange?.(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Position X : {position.split(' ')[0]}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={parseInt(position.split(' ')[0])}
|
||||||
|
onChange={(e) => {
|
||||||
|
const y = position.split(' ')[1];
|
||||||
|
onPositionChange?.(`${e.target.value}% ${y}`);
|
||||||
|
}}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Position Y : {position.split(' ')[1]}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={parseInt(position.split(' ')[1])}
|
||||||
|
onChange={(e) => {
|
||||||
|
const x = position.split(' ')[0];
|
||||||
|
onPositionChange?.(`${x} ${e.target.value}%`);
|
||||||
|
}}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*"
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
|
|||||||
const quillRef = useRef<any>(null);
|
const quillRef = useRef<any>(null);
|
||||||
|
|
||||||
const modules = useMemo(() => ({
|
const modules = useMemo(() => ({
|
||||||
|
table: true,
|
||||||
toolbar: {
|
toolbar: {
|
||||||
container: [
|
container: [
|
||||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
['divider', 'link', 'clean'],
|
['table', 'divider', 'link', 'clean'],
|
||||||
],
|
],
|
||||||
handlers: {
|
handlers: {
|
||||||
divider: function() {
|
divider: function() {
|
||||||
@@ -58,7 +59,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
|
|||||||
'header',
|
'header',
|
||||||
'bold', 'italic', 'underline', 'strike',
|
'bold', 'italic', 'underline', 'strike',
|
||||||
'list',
|
'list',
|
||||||
'divider', 'link',
|
'divider', 'link', 'table'
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
// Form state
|
// Form state
|
||||||
const [type, setType] = useState(initialData?.type || 'CUSTOM');
|
const [type, setType] = useState(initialData?.type || 'CUSTOM');
|
||||||
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
|
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
|
||||||
|
|
||||||
// Business search state
|
// Business search state
|
||||||
@@ -55,6 +57,8 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
||||||
order: parseInt(formData.get('order') as string) || 0,
|
order: parseInt(formData.get('order') as string) || 0,
|
||||||
isActive,
|
isActive,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'BUSINESS' && !data.businessId) {
|
if (type === 'BUSINESS' && !data.businessId) {
|
||||||
@@ -232,6 +236,11 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
value={imageUrl}
|
value={imageUrl}
|
||||||
onChange={setImageUrl}
|
onChange={setImageUrl}
|
||||||
name="imageUrl"
|
name="imageUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function createEvent(data: {
|
|||||||
thumbnailUrl: data.thumbnailUrl,
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
link: data.link || null,
|
link: data.link || null,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
status: ContentStatus.PENDING, // Always pending for user submission
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function createNews(data: {
|
|||||||
author: string;
|
author: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.title
|
const slug = data.title
|
||||||
@@ -26,12 +27,13 @@ export async function createNews(data: {
|
|||||||
author: data.author,
|
author: data.author,
|
||||||
imageUrl: data.imageUrl,
|
imageUrl: data.imageUrl,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
status: ContentStatus.PENDING, // Always pending for user submission
|
sources: data.sources || [],
|
||||||
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
return { success: true, post };
|
return { success: true, post };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create news:", error);
|
console.error("Failed to create news:", error);
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ export async function uploadImage(formData: FormData) {
|
|||||||
|
|
||||||
// Convert to Base64
|
// Convert to Base64
|
||||||
const base64 = buffer.toString('base64');
|
const base64 = buffer.toString('base64');
|
||||||
const mimeType = file.type || 'image/jpeg';
|
let mimeType = file.type || 'image/jpeg';
|
||||||
|
|
||||||
|
// Normalize JPEG mime types
|
||||||
|
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
|
||||||
|
mimeType = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
// Return the data URL directly (it will be saved in the database)
|
// Return the data URL directly (it will be saved in the database)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
|||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
import { prisma } from '../../../lib/prisma';
|
import { prisma } from '../../../lib/prisma';
|
||||||
import BlogShareButtons from '../../../components/BlogShareButtons';
|
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||||
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -117,11 +118,12 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
|
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
|
||||||
{/* Image d'en-tête */}
|
{/* Image d'en-tête */}
|
||||||
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
|
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden shadow-md">
|
||||||
<img
|
<LightboxImage
|
||||||
src={post.imageUrl}
|
src={post.imageUrl}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
className="w-full h-full object-cover"
|
position={post.coverPosition || "50% 50%"}
|
||||||
|
zoom={post.coverZoom || 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
{post.excerpt}
|
{post.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Corps du texte avec ID pour le contrôle CSS */}
|
{/* Corps du texte avec ID pour le contrôle CSS */}
|
||||||
<div
|
<div
|
||||||
id="blog-content"
|
id="blog-content"
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
@@ -168,6 +170,32 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sources Section */}
|
||||||
|
{Array.isArray(post.sources) && post.sources.length > 0 && (
|
||||||
|
<div className="mt-12 p-6 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-400 mb-4 flex items-center gap-2">
|
||||||
|
<Share2 className="w-4 h-4 text-brand-500" />
|
||||||
|
Sources & Références
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{(post.sources as any[]).map((source, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-2 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-700 hover:text-brand-600 transition-colors break-all"
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{source.title || 'Source'}</span>
|
||||||
|
<span className="text-gray-400 ml-2 text-sm">{source.url}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Partage / Footer */}
|
{/* Partage / Footer */}
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||||
|
|||||||
@@ -42,7 +42,16 @@ export default async function BlogPage() {
|
|||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||||
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
||||||
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
<img
|
||||||
|
className="h-full w-full object-cover group-hover:scale-[1.05] transition-transform duration-500"
|
||||||
|
src={post.imageUrl}
|
||||||
|
alt={post.title}
|
||||||
|
style={{
|
||||||
|
objectPosition: post.coverPosition || '50% 50%',
|
||||||
|
transform: `scale(${post.coverZoom || 1})`,
|
||||||
|
transformOrigin: post.coverPosition || '50% 50%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight
|
|||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
import { prisma } from '../../../lib/prisma';
|
import { prisma } from '../../../lib/prisma';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType } from '@prisma/client';
|
||||||
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
|
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
@@ -161,8 +162,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10">
|
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10">
|
||||||
<img src={item!.thumbnailUrl} alt={item!.title} className="w-full h-full object-cover" />
|
<LightboxImage
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
src={item!.thumbnailUrl}
|
||||||
|
alt={item!.title}
|
||||||
|
position={(item as any).coverPosition || "50% 50%"}
|
||||||
|
zoom={(item as any).coverZoom || 1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
<img
|
<img
|
||||||
src={item.thumbnailUrl}
|
src={item.thumbnailUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-[1.05] transition-transform duration-700"
|
||||||
|
style={{
|
||||||
|
objectPosition: item.coverPosition || '50% 50%',
|
||||||
|
transform: `scale(${item.coverZoom || 1})`,
|
||||||
|
transformOrigin: item.coverPosition || '50% 50%'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,33 @@ h4 {
|
|||||||
margin-bottom: 1.25em;
|
margin-bottom: 1.25em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles pour les tableaux dans le contenu */
|
||||||
|
#blog-content table,
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content th,
|
||||||
|
#blog-content td,
|
||||||
|
.prose th,
|
||||||
|
.prose td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content th,
|
||||||
|
.prose th {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content tr:nth-child(even),
|
||||||
|
.prose tr:nth-child(even) {
|
||||||
|
background-color: #fcfcfc;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ interface HomeSlide {
|
|||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
linkUrl?: string | null;
|
linkUrl?: string | null;
|
||||||
|
coverPosition?: string | null;
|
||||||
|
coverZoom?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -85,10 +87,15 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
|
|||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
|
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
|
||||||
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
|
||||||
index === current ? 'scale-110' : 'scale-100'
|
|
||||||
}`}
|
|
||||||
alt=""
|
alt=""
|
||||||
|
style={{
|
||||||
|
objectPosition: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
|
||||||
|
transformOrigin: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
|
||||||
|
'--base-zoom': slide.type === 'BUSINESS' ? (slide.business?.coverZoom || 1) : (slide.coverZoom || 1)
|
||||||
|
} as any}
|
||||||
|
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
||||||
|
index === current ? 'scale-[calc(var(--base-zoom)*1.1)]' : 'scale-[var(--base-zoom)]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
components/LightboxImage.tsx
Normal file
99
components/LightboxImage.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Maximize2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LightboxImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
position?: string;
|
||||||
|
zoom?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LightboxImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
position = "50% 50%",
|
||||||
|
zoom = 1,
|
||||||
|
className = "w-full h-full object-cover"
|
||||||
|
}: LightboxImageProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEsc);
|
||||||
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Prevent scroll when open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = 'unset'; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="relative group cursor-zoom-in w-full h-full overflow-hidden"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
objectPosition: position,
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: position
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||||
|
<div className="bg-white/20 backdrop-blur-md p-3 rounded-full text-white scale-90 group-hover:scale-100 transition-all">
|
||||||
|
<Maximize2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox Overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center p-4 md:p-12 animate-in fade-in duration-300"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-[10000]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative w-full h-full flex items-center justify-center cursor-zoom-out"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="max-w-full max-h-full object-contain shadow-2xl animate-in zoom-in-95 duration-300"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legend / Caption */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 to-transparent text-white text-center">
|
||||||
|
<p className="text-lg font-medium">{alt}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -171,10 +171,10 @@ export default function CreateEventModal({ isOpen, onClose }: CreateEventModalPr
|
|||||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -170,10 +170,10 @@ export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProp
|
|||||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ model BlogPost {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
|
sources Json? @default("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
model HomeSlide {
|
model HomeSlide {
|
||||||
@@ -174,6 +177,8 @@ model HomeSlide {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
business Business? @relation(fields: [businessId], references: [id])
|
business Business? @relation(fields: [businessId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +203,8 @@ model Interview {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -358,6 +365,8 @@ model Event {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
status ContentStatus @default(PUBLISHED)
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Favorite {
|
model Favorite {
|
||||||
|
|||||||
4
scratch/debug-enum.ts
Normal file
4
scratch/debug-enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
console.log('Available ContentStatus:', Object.values(ContentStatus));
|
||||||
|
console.log('PENDING value:', ContentStatus.PENDING);
|
||||||
3
scratch/debug-prisma.ts
Normal file
3
scratch/debug-prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { prisma } from './lib/prisma';
|
||||||
|
|
||||||
|
console.log('Prisma models:', Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$')));
|
||||||
Reference in New Issue
Block a user