feat: implement event submission flow with image upload and modal interface

This commit is contained in:
2026-05-10 21:47:28 +02:00
parent 7db35361d9
commit 197594f84f
31 changed files with 1481 additions and 75 deletions

631
admin/package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@types/bcryptjs": "^2.4.6",
"afrohub": "file:..",
"bcryptjs": "^3.0.3",
"d3-scale": "^4.0.2",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
@@ -22,14 +23,20 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"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": {
"@tailwindcss/postcss": "^4",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
@@ -1379,6 +1386,42 @@
"@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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1392,6 +1435,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1689,6 +1738,98 @@
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1696,6 +1837,13 @@
"dev": true,
"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": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1752,6 +1900,46 @@
"@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": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
@@ -2859,6 +3047,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2879,6 +3076,12 @@
"dev": true,
"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": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2929,6 +3132,226 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"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": {
"version": "1.0.8",
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3346,6 +3775,16 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4297,6 +4736,16 @@
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4339,6 +4788,15 @@
"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": {
"version": "3.0.5",
"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"
}
},
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -6175,6 +6673,51 @@
"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": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6219,6 +6762,12 @@
"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": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -6820,6 +7369,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
@@ -6890,6 +7445,20 @@
"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": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7030,7 +7599,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -7166,6 +7735,64 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -15,6 +15,7 @@
"@types/bcryptjs": "^2.4.6",
"afrohub": "file:..",
"bcryptjs": "^3.0.3",
"d3-scale": "^4.0.2",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
@@ -23,14 +24,20 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"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": {
"@tailwindcss/postcss": "^4",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",

View File

@@ -160,6 +160,8 @@ model BlogPost {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model HomeSlide {
@@ -174,6 +176,8 @@ model HomeSlide {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
business Business? @relation(fields: [businessId], references: [id])
}
@@ -198,6 +202,8 @@ model Interview {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Comment {
@@ -358,6 +364,8 @@ model Event {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Favorite {

View File

@@ -17,6 +17,9 @@ export async function createBlogPost(data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -51,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -18,6 +18,8 @@ export async function createEvent(data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -53,6 +55,8 @@ export async function updateEvent(id: string, data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -27,6 +27,8 @@ export async function createSlide(data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");
@@ -50,6 +52,8 @@ export async function updateSlide(id: string, data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");

View File

@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
const buffer = Buffer.from(bytes);
// 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}`;
// Path for the ROOT public/uploads (where the main site will look)

View File

@@ -299,3 +299,23 @@ body {
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
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;
}

View File

@@ -13,8 +13,11 @@ import {
Search,
ArrowUp,
ArrowDown,
Calendar
Calendar,
Map as MapIcon
} from 'lucide-react';
import AnalyticsMap from '@/components/AnalyticsMap';
import CountryHistogram from '@/components/CountryHistogram';
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
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({
by: ['country'],
where: currentWhere,
where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true },
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({
by: ['device'],
where: currentWhere,
@@ -156,6 +175,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
totalEvents,
uniqueVisitors,
recentIPs,
uniqueVisitorsPerCountry,
deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 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 }> }) {
const searchParams = await searchParamsPromise;
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> = {
day: 'Dernières 24h',
@@ -237,13 +257,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Users className="w-6 h-6" />
</div>
<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} />
</div>
</div>
<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">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Personnes réelles distinctes
</p>
</div>
@@ -253,13 +273,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Eye className="w-6 h-6" />
</div>
<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} />
</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">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Nombre total de fois les pages ont é vues
</p>
</div>
@@ -275,13 +295,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<ComparisonBadge value={deltas.mobile} />
</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">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Part des appareils mobiles
</p>
</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">
{/* Top Countries Table */}
<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">
<tr>
<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>
</thead>
<tbody className="divide-y divide-slate-800">
{topCountries.map((c) => (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{c._count.id}
</td>
</tr>
))}
{topCountries.map((c) => {
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
return (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
{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>
</table>
</div>

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
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 { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
@@ -26,6 +26,9 @@ interface Props {
metaDescription?: string | null;
publishedAt?: Date | string | null;
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 [content, setContent] = useState(initialData?.content || '');
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 [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
@@ -41,6 +46,9 @@ export default function BlogForm({ initialData }: Props) {
? new Date(initialData.publishedAt).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();
@@ -65,6 +73,9 @@ export default function BlogForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
sources: sources,
};
if (!data.imageUrl) {
@@ -148,6 +159,11 @@ export default function BlogForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">
@@ -187,6 +203,61 @@ export default function BlogForm({ initialData }: Props) {
placeholder="Rédigez votre article ici..."
/>
</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>
{/* SEO SECTION */}

View 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>
);
}

View 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>
);
}

View File

@@ -21,6 +21,8 @@ export default function EventForm({ initialData }: Props) {
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
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 [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
@@ -53,6 +55,8 @@ export default function EventForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
if (!data.thumbnailUrl) {
@@ -170,6 +174,11 @@ export default function EventForm({ initialData }: Props) {
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">

View File

@@ -11,9 +11,25 @@ interface Props {
label?: string;
placeholder?: 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 [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
return;
}
@@ -120,37 +137,111 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
</div>
</div>
) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
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">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
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" />
</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 className="space-y-4">
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
style={{
objectPosition: position,
transform: `scale(${zoom})`,
transformOrigin: position
}}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
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" />
</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>
{/* 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>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
className="hidden"
/>
</div>

View File

@@ -34,12 +34,13 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
const quillRef = useRef<any>(null);
const modules = useMemo(() => ({
table: true,
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['divider', 'link', 'clean'],
['table', 'divider', 'link', 'clean'],
],
handlers: {
divider: function() {
@@ -58,7 +59,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
'header',
'bold', 'italic', 'underline', 'strike',
'list',
'divider', 'link',
'divider', 'link', 'table'
];
return (

View File

@@ -19,6 +19,8 @@ export default function SlideForm({ initialData }: Props) {
// Form state
const [type, setType] = useState(initialData?.type || 'CUSTOM');
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);
// Business search state
@@ -55,6 +57,8 @@ export default function SlideForm({ initialData }: Props) {
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
order: parseInt(formData.get('order') as string) || 0,
isActive,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
if (type === 'BUSINESS' && !data.businessId) {
@@ -232,6 +236,11 @@ export default function SlideForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
)}

View File

@@ -28,7 +28,7 @@ export async function createEvent(data: {
thumbnailUrl: data.thumbnailUrl,
link: data.link || null,
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)}`
}
});

View File

@@ -11,6 +11,7 @@ export async function createNews(data: {
author: string;
imageUrl: string;
tags?: string[];
sources?: any;
}) {
try {
const slug = data.title
@@ -26,12 +27,13 @@ export async function createNews(data: {
author: data.author,
imageUrl: data.imageUrl,
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)}`
}
});
revalidatePath("/blog");
revalidatePath("/actualites");
return { success: true, post };
} catch (error) {
console.error("Failed to create news:", error);

View File

@@ -13,7 +13,13 @@ export async function uploadImage(formData: FormData) {
// Convert to 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}`;
// Return the data URL directly (it will be saved in the database)

View File

@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import BlogShareButtons from '../../../components/BlogShareButtons';
import LightboxImage from '@/components/LightboxImage';
import { Metadata } from 'next';
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">
{/* Image d'en-tête */}
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
<img
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden shadow-md">
<LightboxImage
src={post.imageUrl}
alt={post.title}
className="w-full h-full object-cover"
position={post.coverPosition || "50% 50%"}
zoom={post.coverZoom || 1}
/>
</div>
@@ -160,7 +162,7 @@ export default async function BlogPostPage({ params }: Props) {
{post.excerpt}
</p>
{/* Corps du texte avec ID pour le contrôle CSS */}
{/* Corps du texte avec ID pour le contrôle CSS */}
<div
id="blog-content"
className="mt-6"
@@ -168,6 +170,32 @@ export default async function BlogPostPage({ params }: Props) {
/>
</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 */}
<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>

View File

@@ -42,7 +42,16 @@ export default async function BlogPage() {
{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">
<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>
<div className="flex-1 bg-white p-6 flex flex-col justify-between">

View File

@@ -5,6 +5,7 @@ import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client';
import LightboxImage from '@/components/LightboxImage';
import { Metadata } from 'next';
@@ -161,8 +162,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
</div>
) : (
<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" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
<LightboxImage
src={item!.thumbnailUrl}
alt={item!.title}
position={(item as any).coverPosition || "50% 50%"}
zoom={(item as any).coverZoom || 1}
/>
</div>
)}

View File

@@ -149,7 +149,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
<img
src={item.thumbnailUrl}
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>

View File

@@ -58,3 +58,33 @@ h4 {
margin-bottom: 1.25em;
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;
}

View File

@@ -13,6 +13,8 @@ interface HomeSlide {
subtitle?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
coverPosition?: string | null;
coverZoom?: number | null;
}
interface Props {
@@ -85,10 +87,15 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
<div className="absolute inset-0">
<img
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=""
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>

View 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>
)}
</>
);
}

View File

@@ -171,10 +171,10 @@ export default function CreateEventModal({ isOpen, onClose }: CreateEventModalPr
<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-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
type="file"
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>

View File

@@ -170,10 +170,10 @@ export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProp
<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-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
type="file"
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>

View File

@@ -160,6 +160,9 @@ model BlogPost {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
sources Json? @default("[]")
}
model HomeSlide {
@@ -174,6 +177,8 @@ model HomeSlide {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
business Business? @relation(fields: [businessId], references: [id])
}
@@ -198,6 +203,8 @@ model Interview {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Comment {
@@ -358,6 +365,8 @@ model Event {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Favorite {

4
scratch/debug-enum.ts Normal file
View 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
View File

@@ -0,0 +1,3 @@
import { prisma } from './lib/prisma';
console.log('Prisma models:', Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$')));