feat(blog): Phase 3 GEO/IA, corbeille, duplication, drag&drop, recadrage
- Bloc GEO/IA (resume IA, FAQ, points cles, entites) + rendu public "En bref"/"A retenir"/FAQ + JSON-LD Article & FAQPage. - Corbeille: soft-delete (deleted_at), filtres Tous/Publies/Brouillons/ Planifies/Corbeille, restauration + suppression definitive. - Duplication d'un article (nouveau brouillon, slug unique). - Drag & drop de l'image de couverture. - Recadrage auto de la couverture en 16:9 (sharp) via endpoint dedie /admin/blog/cover-images. - Migration AddGeoAndTrashToBlogPosts (ai_summary, faq, key_takeaways, ai_entities, deleted_at). Ajout dependance sharp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7234343e20
commit
8877265526
614
apps/backend/package-lock.json
generated
614
apps/backend/package-lock.json
generated
@ -60,6 +60,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"stripe": "^14.14.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
@ -1444,6 +1445,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
@ -1696,6 +1707,554 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ioredis/as-callback": {
|
"node_modules/@ioredis/as-callback": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz",
|
||||||
@ -12858,9 +13417,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@ -12972,6 +13531,55 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.3",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.3",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||||
|
"@img/sharp-linux-arm": "0.35.3",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.3",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.3",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.3",
|
||||||
|
"@img/sharp-linux-x64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.3",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.3",
|
||||||
|
"@img/sharp-win32-x64": "0.35.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@ -76,6 +76,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"stripe": "^14.14.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@ -1001,17 +1002,79 @@ export class AdminController {
|
|||||||
return { url: `/api/v1/blog/images/${filename}`, filename };
|
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('blog/cover-images')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('image', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
// SVG cannot be raster-cropped; restrict to raster formats.
|
||||||
|
if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new BadRequestException('Only JPG, PNG, WebP or GIF images are allowed'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Upload and auto-crop a blog cover image (Admin only)',
|
||||||
|
description:
|
||||||
|
'Resizes and crops the image to the public 16:9 card ratio (1280x720) so covers are never distorted or cut off on the public site.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
|
||||||
|
})
|
||||||
|
async uploadBlogCoverImage(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ url: string; filename: string }> {
|
||||||
|
if (!file) throw new BadRequestException('No image file provided');
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Uploading blog cover image: ${file.originalname}`);
|
||||||
|
|
||||||
|
// Crop to the public blog card ratio (16:9). "attention" focuses on the most
|
||||||
|
// salient region so important content is preserved.
|
||||||
|
let processed: Buffer;
|
||||||
|
try {
|
||||||
|
processed = await sharp(file.buffer)
|
||||||
|
.resize(1280, 720, { fit: 'cover', position: sharp.strategy.attention })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Failed to process cover image: ${err?.message}`);
|
||||||
|
throw new BadRequestException("Impossible de traiter l'image");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${uuidv4()}-cover.webp`;
|
||||||
|
const key = `blog-images/${filename}`;
|
||||||
|
|
||||||
|
await this.storage.upload({
|
||||||
|
bucket: BLOG_IMAGES_BUCKET,
|
||||||
|
key,
|
||||||
|
body: processed,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] Blog cover image uploaded: ${key}`);
|
||||||
|
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('blog')
|
@Get('blog')
|
||||||
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
||||||
@ApiQuery({ name: 'status', required: false })
|
@ApiQuery({ name: 'status', required: false })
|
||||||
@ApiQuery({ name: 'category', required: false })
|
@ApiQuery({ name: 'category', required: false })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: 'search', required: false })
|
||||||
|
@ApiQuery({ name: 'trashed', required: false, type: Boolean })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
async listBlogPosts(
|
async listBlogPosts(
|
||||||
@Query('status') status?: any,
|
@Query('status') status?: any,
|
||||||
@Query('category') category?: BlogPostCategory,
|
@Query('category') category?: BlogPostCategory,
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
|
@Query('trashed') trashed?: string,
|
||||||
@Query('limit') limit = 50,
|
@Query('limit') limit = 50,
|
||||||
@Query('offset') offset = 0,
|
@Query('offset') offset = 0,
|
||||||
@CurrentUser() user?: UserPayload
|
@CurrentUser() user?: UserPayload
|
||||||
@ -1021,6 +1084,7 @@ export class AdminController {
|
|||||||
status,
|
status,
|
||||||
category,
|
category,
|
||||||
search,
|
search,
|
||||||
|
trashed: trashed === 'true',
|
||||||
limit: Number(limit),
|
limit: Number(limit),
|
||||||
offset: Number(offset),
|
offset: Number(offset),
|
||||||
});
|
});
|
||||||
@ -1051,15 +1115,45 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('blog/:id')
|
@Delete('blog/:id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
|
@ApiOperation({ summary: 'Move a blog post to the trash (Admin only)' })
|
||||||
async deleteBlogPost(
|
async deleteBlogPost(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
|
this.logger.log(`[ADMIN: ${user.email}] Trashing blog post: ${id}`);
|
||||||
await this.blogService.deletePost(id);
|
await this.blogService.deletePost(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('blog/:id/restore')
|
||||||
|
@ApiOperation({ summary: 'Restore a blog post from the trash (Admin only)' })
|
||||||
|
async restoreBlogPost(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Restoring blog post: ${id}`);
|
||||||
|
const post = await this.blogService.restorePost(id);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('blog/:id/permanent')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Permanently delete a blog post (Admin only)' })
|
||||||
|
async permanentlyDeleteBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Permanently deleting blog post: ${id}`);
|
||||||
|
await this.blogService.permanentlyDeletePost(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('blog/:id/duplicate')
|
||||||
|
@ApiOperation({ summary: 'Duplicate a blog post as a new draft (Admin only)' })
|
||||||
|
async duplicateBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Duplicating blog post: ${id}`);
|
||||||
|
const post = await this.blogService.duplicatePost(id);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
private mapBlogPostToDto(post: BlogPost) {
|
private mapBlogPostToDto(post: BlogPost) {
|
||||||
return {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@ -1078,6 +1172,10 @@ export class AdminController {
|
|||||||
metaDescription: post.metaDescription,
|
metaDescription: post.metaDescription,
|
||||||
primaryKeyword: post.primaryKeyword,
|
primaryKeyword: post.primaryKeyword,
|
||||||
secondaryKeywords: post.secondaryKeywords,
|
secondaryKeywords: post.secondaryKeywords,
|
||||||
|
aiSummary: post.aiSummary,
|
||||||
|
faq: post.faq,
|
||||||
|
keyTakeaways: post.keyTakeaways,
|
||||||
|
aiEntities: post.aiEntities,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
updatedAt: post.updatedAt,
|
updatedAt: post.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -128,6 +128,10 @@ export class BlogController {
|
|||||||
metaDescription: post.metaDescription,
|
metaDescription: post.metaDescription,
|
||||||
primaryKeyword: post.primaryKeyword,
|
primaryKeyword: post.primaryKeyword,
|
||||||
secondaryKeywords: post.secondaryKeywords,
|
secondaryKeywords: post.secondaryKeywords,
|
||||||
|
aiSummary: post.aiSummary ?? undefined,
|
||||||
|
faq: post.faq,
|
||||||
|
keyTakeaways: post.keyTakeaways,
|
||||||
|
aiEntities: post.aiEntities,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
updatedAt: post.updatedAt,
|
updatedAt: post.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,4 @@
|
|||||||
import {
|
import { Controller, Get, Post, Param, Query, Body, Res, StreamableFile } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
Body,
|
|
||||||
Res,
|
|
||||||
StreamableFile,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
@ -251,10 +242,7 @@ export class CsvBookingActionsController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
res.setHeader('Content-Type', mimeType);
|
res.setHeader('Content-Type', mimeType);
|
||||||
res.setHeader(
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||||
'Content-Disposition',
|
|
||||||
`attachment; filename="${encodeURIComponent(fileName)}"`
|
|
||||||
);
|
|
||||||
return new StreamableFile(buffer);
|
return new StreamableFile(buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,10 +42,7 @@ import {
|
|||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
import {
|
import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
|
||||||
NotificationType,
|
|
||||||
NotificationPriority,
|
|
||||||
} from '@domain/entities/notification.entity';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
@ -71,7 +68,8 @@ export class OrganizationsController {
|
|||||||
private readonly logger = new Logger(OrganizationsController.name);
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
private readonly notificationService: NotificationService
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
@ -320,9 +318,7 @@ export class OrganizationsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
||||||
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
||||||
async requestSiretApproval(
|
async requestSiretApproval(@CurrentUser() user: UserPayload): Promise<{ message: string }> {
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const organization = await this.organizationRepository.findById(user.organizationId);
|
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new NotFoundException('Organization not found');
|
throw new NotFoundException('Organization not found');
|
||||||
|
|||||||
@ -11,12 +11,63 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
Matches,
|
Matches,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
BlogPostStatus,
|
||||||
|
type BlogPostCategory,
|
||||||
|
type BlogFaqItem,
|
||||||
|
} from '@domain/entities/blog-post.entity';
|
||||||
|
|
||||||
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
||||||
|
|
||||||
export class CreateBlogPostDto {
|
export class BlogFaqItemDto implements BlogFaqItem {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(500)
|
||||||
|
question: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(4000)
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEO (Generative Engine Optimisation) fields, shared by create/update DTOs.
|
||||||
|
*/
|
||||||
|
export class BlogGeoFields {
|
||||||
|
@ApiPropertyOptional({ description: 'AI summary / TL;DR of the article' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BlogFaqItemDto], description: 'FAQ (question/answer pairs)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BlogFaqItemDto)
|
||||||
|
faq?: BlogFaqItemDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Key takeaways' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Related entities / topics' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
aiEntities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBlogPostDto extends BlogGeoFields {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -95,7 +146,7 @@ export class CreateBlogPostDto {
|
|||||||
secondaryKeywords?: string[];
|
secondaryKeywords?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBlogPostDto {
|
export class UpdateBlogPostDto extends BlogGeoFields {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -206,6 +257,10 @@ export class BlogPostResponseDto {
|
|||||||
@ApiPropertyOptional() metaDescription?: string;
|
@ApiPropertyOptional() metaDescription?: string;
|
||||||
@ApiPropertyOptional() primaryKeyword?: string;
|
@ApiPropertyOptional() primaryKeyword?: string;
|
||||||
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
|
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
|
||||||
|
@ApiPropertyOptional() aiSummary?: string | null;
|
||||||
|
@ApiProperty({ type: [BlogFaqItemDto] }) faq: BlogFaqItemDto[];
|
||||||
|
@ApiProperty({ type: [String] }) keyTakeaways: string[];
|
||||||
|
@ApiProperty({ type: [String] }) aiEntities: string[];
|
||||||
@ApiProperty() createdAt: Date;
|
@ApiProperty() createdAt: Date;
|
||||||
@ApiProperty() updatedAt: Date;
|
@ApiProperty() updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
|
|||||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([OrganizationOrmEntity]), UsersModule, NotificationsModule],
|
||||||
TypeOrmModule.forFeature([OrganizationOrmEntity]),
|
|
||||||
UsersModule,
|
|
||||||
NotificationsModule,
|
|
||||||
],
|
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -50,11 +50,17 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
|||||||
cache: any,
|
cache: any,
|
||||||
rateQuoteRepo: any,
|
rateQuoteRepo: any,
|
||||||
portRepo: any,
|
portRepo: any,
|
||||||
carrierRepo: any,
|
carrierRepo: any
|
||||||
) => {
|
) => {
|
||||||
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
|
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||||
},
|
},
|
||||||
inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
inject: [
|
||||||
|
'CarrierConnectors',
|
||||||
|
CACHE_PORT,
|
||||||
|
RATE_QUOTE_REPOSITORY,
|
||||||
|
PORT_REPOSITORY,
|
||||||
|
CARRIER_REPOSITORY,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
||||||
|
|||||||
@ -58,6 +58,10 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
metaDescription: dto.metaDescription,
|
metaDescription: dto.metaDescription,
|
||||||
primaryKeyword: dto.primaryKeyword,
|
primaryKeyword: dto.primaryKeyword,
|
||||||
secondaryKeywords: dto.secondaryKeywords ?? [],
|
secondaryKeywords: dto.secondaryKeywords ?? [],
|
||||||
|
aiSummary: dto.aiSummary,
|
||||||
|
faq: dto.faq ?? [],
|
||||||
|
keyTakeaways: dto.keyTakeaways ?? [],
|
||||||
|
aiEntities: dto.aiEntities ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.scheduledAt) {
|
if (dto.scheduledAt) {
|
||||||
@ -92,6 +96,10 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
metaDescription: dto.metaDescription,
|
metaDescription: dto.metaDescription,
|
||||||
primaryKeyword: dto.primaryKeyword,
|
primaryKeyword: dto.primaryKeyword,
|
||||||
secondaryKeywords: dto.secondaryKeywords,
|
secondaryKeywords: dto.secondaryKeywords,
|
||||||
|
aiSummary: dto.aiSummary,
|
||||||
|
faq: dto.faq,
|
||||||
|
keyTakeaways: dto.keyTakeaways,
|
||||||
|
aiEntities: dto.aiEntities,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.scheduledAt) {
|
if (dto.scheduledAt) {
|
||||||
@ -108,11 +116,83 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
return this.blogPostRepository.save(updated);
|
return this.blogPostRepository.save(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a post: move it to the trash. It can be restored later or
|
||||||
|
* permanently deleted from the trash.
|
||||||
|
*/
|
||||||
async deletePost(id: string): Promise<void> {
|
async deletePost(id: string): Promise<void> {
|
||||||
await this.findOrFail(id);
|
const post = await this.findOrFail(id);
|
||||||
|
await this.blogPostRepository.save(post.trash());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a post from the trash. */
|
||||||
|
async restorePost(id: string): Promise<BlogPost> {
|
||||||
|
const post = await this.findOrFail(id);
|
||||||
|
return this.blogPostRepository.save(post.restore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete a post (hard delete). Also removes its cover image from
|
||||||
|
* storage when it is a locally hosted blog image (best-effort).
|
||||||
|
*/
|
||||||
|
async permanentlyDeletePost(id: string): Promise<void> {
|
||||||
|
const post = await this.findOrFail(id);
|
||||||
|
|
||||||
|
const coverUrl = post.coverImageUrl;
|
||||||
|
if (coverUrl) {
|
||||||
|
const match = coverUrl.match(/\/api\/v1\/blog\/images\/([^/?#]+)$/);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.storage.delete({ bucket: BLOG_IMAGES_BUCKET, key: `blog-images/${match[1]}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not delete cover image for post ${id}: ${err?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.blogPostRepository.delete(id);
|
await this.blogPostRepository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a post as a new draft with a unique slug.
|
||||||
|
*/
|
||||||
|
async duplicatePost(id: string): Promise<BlogPost> {
|
||||||
|
const source = await this.findOrFail(id);
|
||||||
|
const slug = await this.generateUniqueSlug(source.slug);
|
||||||
|
|
||||||
|
const copy = BlogPost.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
title: `${source.title} (copie)`,
|
||||||
|
slug,
|
||||||
|
excerpt: source.excerpt,
|
||||||
|
content: source.content,
|
||||||
|
coverImageUrl: source.coverImageUrl,
|
||||||
|
category: source.category,
|
||||||
|
tags: [...source.tags],
|
||||||
|
authorName: source.authorName,
|
||||||
|
metaTitle: source.metaTitle,
|
||||||
|
metaDescription: source.metaDescription,
|
||||||
|
primaryKeyword: source.primaryKeyword,
|
||||||
|
secondaryKeywords: [...source.secondaryKeywords],
|
||||||
|
aiSummary: source.aiSummary,
|
||||||
|
faq: source.faq.map(item => ({ ...item })),
|
||||||
|
keyTakeaways: [...source.keyTakeaways],
|
||||||
|
aiEntities: [...source.aiEntities],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.blogPostRepository.save(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateUniqueSlug(baseSlug: string): Promise<string> {
|
||||||
|
let candidate = `${baseSlug}-copie`;
|
||||||
|
let counter = 2;
|
||||||
|
while (await this.blogPostRepository.slugExists(candidate)) {
|
||||||
|
candidate = `${baseSlug}-copie-${counter}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
async getPostById(id: string): Promise<BlogPost> {
|
async getPostById(id: string): Promise<BlogPost> {
|
||||||
return this.findOrFail(id);
|
return this.findOrFail(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,12 @@ export enum BlogPostStatus {
|
|||||||
|
|
||||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||||
|
|
||||||
|
/** A single question/answer pair used for GEO (AI) optimisation and FAQ schema. */
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -24,6 +30,13 @@ interface BlogPostProps {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords: string[];
|
secondaryKeywords: string[];
|
||||||
|
// GEO (Generative Engine Optimisation) — helps AI/LLM engines cite the article.
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq: BlogFaqItem[];
|
||||||
|
keyTakeaways: string[];
|
||||||
|
aiEntities: string[];
|
||||||
|
// Soft delete (trash). Null/undefined means the post is not trashed.
|
||||||
|
deletedAt?: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -32,12 +45,27 @@ export class BlogPost {
|
|||||||
private constructor(private readonly props: BlogPostProps) {}
|
private constructor(private readonly props: BlogPostProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
|
props: Omit<
|
||||||
|
BlogPostProps,
|
||||||
|
| 'status'
|
||||||
|
| 'isFeatured'
|
||||||
|
| 'publishedAt'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'updatedAt'
|
||||||
|
| 'faq'
|
||||||
|
| 'keyTakeaways'
|
||||||
|
| 'aiEntities'
|
||||||
|
| 'deletedAt'
|
||||||
|
> &
|
||||||
|
Partial<Pick<BlogPostProps, 'faq' | 'keyTakeaways' | 'aiEntities'>>
|
||||||
): BlogPost {
|
): BlogPost {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new BlogPost({
|
return new BlogPost({
|
||||||
...props,
|
...props,
|
||||||
secondaryKeywords: props.secondaryKeywords ?? [],
|
secondaryKeywords: props.secondaryKeywords ?? [],
|
||||||
|
faq: props.faq ?? [],
|
||||||
|
keyTakeaways: props.keyTakeaways ?? [],
|
||||||
|
aiEntities: props.aiEntities ?? [],
|
||||||
status: BlogPostStatus.DRAFT,
|
status: BlogPostStatus.DRAFT,
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@ -97,6 +125,24 @@ export class BlogPost {
|
|||||||
get secondaryKeywords(): string[] {
|
get secondaryKeywords(): string[] {
|
||||||
return this.props.secondaryKeywords;
|
return this.props.secondaryKeywords;
|
||||||
}
|
}
|
||||||
|
get aiSummary(): string | null | undefined {
|
||||||
|
return this.props.aiSummary;
|
||||||
|
}
|
||||||
|
get faq(): BlogFaqItem[] {
|
||||||
|
return this.props.faq;
|
||||||
|
}
|
||||||
|
get keyTakeaways(): string[] {
|
||||||
|
return this.props.keyTakeaways;
|
||||||
|
}
|
||||||
|
get aiEntities(): string[] {
|
||||||
|
return this.props.aiEntities;
|
||||||
|
}
|
||||||
|
get deletedAt(): Date | null | undefined {
|
||||||
|
return this.props.deletedAt;
|
||||||
|
}
|
||||||
|
get isTrashed(): boolean {
|
||||||
|
return !!this.props.deletedAt;
|
||||||
|
}
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
@ -121,12 +167,26 @@ export class BlogPost {
|
|||||||
| 'metaDescription'
|
| 'metaDescription'
|
||||||
| 'primaryKeyword'
|
| 'primaryKeyword'
|
||||||
| 'secondaryKeywords'
|
| 'secondaryKeywords'
|
||||||
|
| 'aiSummary'
|
||||||
|
| 'faq'
|
||||||
|
| 'keyTakeaways'
|
||||||
|
| 'aiEntities'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
): BlogPost {
|
): BlogPost {
|
||||||
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move the post to the trash (soft delete). */
|
||||||
|
trash(): BlogPost {
|
||||||
|
return new BlogPost({ ...this.props, deletedAt: new Date(), updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the post from the trash. */
|
||||||
|
restore(): BlogPost {
|
||||||
|
return new BlogPost({ ...this.props, deletedAt: null, updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
publish(): BlogPost {
|
publish(): BlogPost {
|
||||||
return new BlogPost({
|
return new BlogPost({
|
||||||
...this.props,
|
...this.props,
|
||||||
@ -163,6 +223,7 @@ export class BlogPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isVisibleToPublic(): boolean {
|
isVisibleToPublic(): boolean {
|
||||||
|
if (this.props.deletedAt) return false;
|
||||||
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
|
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
|
||||||
if (
|
if (
|
||||||
this.props.status === BlogPostStatus.SCHEDULED &&
|
this.props.status === BlogPostStatus.SCHEDULED &&
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export interface BlogPostFilters {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** When true, also include SCHEDULED posts whose publishedAt <= now */
|
/** When true, also include SCHEDULED posts whose publishedAt <= now */
|
||||||
includeScheduled?: boolean;
|
includeScheduled?: boolean;
|
||||||
|
/** When true, return only trashed posts; otherwise trashed posts are excluded. */
|
||||||
|
trashed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogPostRepository {
|
export interface BlogPostRepository {
|
||||||
|
|||||||
@ -52,6 +52,23 @@ export class BlogPostOrmEntity {
|
|||||||
@Column('jsonb', { default: [] })
|
@Column('jsonb', { default: [] })
|
||||||
secondary_keywords: string[];
|
secondary_keywords: string[];
|
||||||
|
|
||||||
|
// GEO (Generative Engine Optimisation) fields
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
ai_summary?: string | null;
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
faq: { question: string; answer: string }[];
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
key_takeaways: string[];
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
ai_entities: string[];
|
||||||
|
|
||||||
|
// Soft delete (trash)
|
||||||
|
@Column('timestamp', { nullable: true })
|
||||||
|
deleted_at?: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddGeoAndTrashToBlogPosts1748000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns('blog_posts', [
|
||||||
|
// GEO (Generative Engine Optimisation) fields
|
||||||
|
new TableColumn({
|
||||||
|
name: 'ai_summary',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'faq',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'key_takeaways',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'ai_entities',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
// Soft delete (trash)
|
||||||
|
new TableColumn({
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
isNullable: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumns('blog_posts', [
|
||||||
|
'ai_summary',
|
||||||
|
'faq',
|
||||||
|
'key_takeaways',
|
||||||
|
'ai_entities',
|
||||||
|
'deleted_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
||||||
const query = this.ormRepository.createQueryBuilder('post');
|
const query = this.ormRepository.createQueryBuilder('post');
|
||||||
|
|
||||||
|
if (filters.trashed) {
|
||||||
|
query.andWhere('post.deleted_at IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
query.andWhere('post.deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||||
query.andWhere(
|
query.andWhere(
|
||||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||||
@ -65,6 +71,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
async count(filters: BlogPostFilters): Promise<number> {
|
async count(filters: BlogPostFilters): Promise<number> {
|
||||||
const query = this.ormRepository.createQueryBuilder('post');
|
const query = this.ormRepository.createQueryBuilder('post');
|
||||||
|
|
||||||
|
if (filters.trashed) {
|
||||||
|
query.andWhere('post.deleted_at IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
query.andWhere('post.deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||||
query.andWhere(
|
query.andWhere(
|
||||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||||
@ -117,6 +129,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
metaDescription: orm.meta_description,
|
metaDescription: orm.meta_description,
|
||||||
primaryKeyword: orm.primary_keyword,
|
primaryKeyword: orm.primary_keyword,
|
||||||
secondaryKeywords: orm.secondary_keywords ?? [],
|
secondaryKeywords: orm.secondary_keywords ?? [],
|
||||||
|
aiSummary: orm.ai_summary,
|
||||||
|
faq: orm.faq ?? [],
|
||||||
|
keyTakeaways: orm.key_takeaways ?? [],
|
||||||
|
aiEntities: orm.ai_entities ?? [],
|
||||||
|
deletedAt: orm.deleted_at,
|
||||||
createdAt: orm.created_at,
|
createdAt: orm.created_at,
|
||||||
updatedAt: orm.updated_at,
|
updatedAt: orm.updated_at,
|
||||||
});
|
});
|
||||||
@ -140,6 +157,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
orm.meta_description = post.metaDescription;
|
orm.meta_description = post.metaDescription;
|
||||||
orm.primary_keyword = post.primaryKeyword;
|
orm.primary_keyword = post.primaryKeyword;
|
||||||
orm.secondary_keywords = post.secondaryKeywords;
|
orm.secondary_keywords = post.secondaryKeywords;
|
||||||
|
orm.ai_summary = post.aiSummary;
|
||||||
|
orm.faq = post.faq;
|
||||||
|
orm.key_takeaways = post.keyTakeaways;
|
||||||
|
orm.ai_entities = post.aiEntities;
|
||||||
|
orm.deleted_at = post.deletedAt;
|
||||||
orm.created_at = post.createdAt;
|
orm.created_at = post.createdAt;
|
||||||
orm.updated_at = post.updatedAt;
|
orm.updated_at = post.updatedAt;
|
||||||
return orm;
|
return orm;
|
||||||
|
|||||||
@ -192,7 +192,8 @@ export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: bool
|
|||||||
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
||||||
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
||||||
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
||||||
const sameSite = (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
|
const sameSite =
|
||||||
|
(process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
|
||||||
const secure = process.env.NODE_ENV === 'production' || sameSite === 'none';
|
const secure = process.env.NODE_ENV === 'production' || sameSite === 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -6,11 +6,14 @@ import {
|
|||||||
createBlogPost,
|
createBlogPost,
|
||||||
updateBlogPost,
|
updateBlogPost,
|
||||||
deleteBlogPost,
|
deleteBlogPost,
|
||||||
|
restoreBlogPost,
|
||||||
|
permanentlyDeleteBlogPost,
|
||||||
|
duplicateBlogPost,
|
||||||
type CreateBlogPostRequest,
|
type CreateBlogPostRequest,
|
||||||
type UpdateBlogPostRequest,
|
type UpdateBlogPostRequest,
|
||||||
} from '@/lib/api/admin';
|
} from '@/lib/api/admin';
|
||||||
import { upload } from '@/lib/api/client';
|
import { upload } from '@/lib/api/client';
|
||||||
import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog';
|
import type { BlogPost, BlogPostCategory, BlogPostStatus, BlogFaqItem } from '@/lib/api/blog';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { RichTextEditor } from '@/components/blog/RichTextEditor';
|
import { RichTextEditor } from '@/components/blog/RichTextEditor';
|
||||||
import {
|
import {
|
||||||
@ -30,6 +33,9 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Copy,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
|
|
||||||
@ -71,6 +77,11 @@ interface FormData extends CreateBlogPostRequest {
|
|||||||
metaDescription: string;
|
metaDescription: string;
|
||||||
primaryKeyword: string;
|
primaryKeyword: string;
|
||||||
secondaryKeywordsInput: string;
|
secondaryKeywordsInput: string;
|
||||||
|
// GEO / AI optimisation
|
||||||
|
aiSummary: string;
|
||||||
|
faqItems: BlogFaqItem[];
|
||||||
|
keyTakeawaysInput: string;
|
||||||
|
aiEntitiesInput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: FormData = {
|
const EMPTY_FORM: FormData = {
|
||||||
@ -86,6 +97,10 @@ const EMPTY_FORM: FormData = {
|
|||||||
metaDescription: '',
|
metaDescription: '',
|
||||||
primaryKeyword: '',
|
primaryKeyword: '',
|
||||||
secondaryKeywordsInput: '',
|
secondaryKeywordsInput: '',
|
||||||
|
aiSummary: '',
|
||||||
|
faqItems: [],
|
||||||
|
keyTakeawaysInput: '',
|
||||||
|
aiEntitiesInput: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateSlug(title: string): string {
|
function generateSlug(title: string): string {
|
||||||
@ -145,16 +160,23 @@ export default function AdminBlogPage() {
|
|||||||
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||||||
const [scheduledAt, setScheduledAt] = useState('');
|
const [scheduledAt, setScheduledAt] = useState('');
|
||||||
const [seoOpen, setSeoOpen] = useState(false);
|
const [seoOpen, setSeoOpen] = useState(false);
|
||||||
|
const [geoOpen, setGeoOpen] = useState(false);
|
||||||
|
const [isDraggingCover, setIsDraggingCover] = useState(false);
|
||||||
|
const [viewFilter, setViewFilter] = useState<'all' | 'published' | 'draft' | 'scheduled' | 'trash'>(
|
||||||
|
'all'
|
||||||
|
);
|
||||||
|
const [actioningId, setActioningId] = useState<string | null>(null);
|
||||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPosts();
|
fetchPosts();
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [viewFilter]);
|
||||||
|
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getAllBlogPosts();
|
const res = await getAllBlogPosts({ trashed: viewFilter === 'trash' });
|
||||||
setPosts(res.posts);
|
setPosts(res.posts);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -164,6 +186,12 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Client-side status filtering for the non-trash views (posts are already loaded).
|
||||||
|
const visiblePosts =
|
||||||
|
viewFilter === 'all' || viewFilter === 'trash'
|
||||||
|
? posts
|
||||||
|
: posts.filter(p => p.status === viewFilter);
|
||||||
|
|
||||||
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
|
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
|
||||||
const tags = tagsInput
|
const tags = tagsInput
|
||||||
? tagsInput
|
? tagsInput
|
||||||
@ -177,6 +205,21 @@ export default function AdminBlogPage() {
|
|||||||
.map(t => t.trim())
|
.map(t => t.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const keyTakeaways = formData.keyTakeawaysInput
|
||||||
|
? formData.keyTakeawaysInput
|
||||||
|
.split('\n')
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const aiEntities = formData.aiEntitiesInput
|
||||||
|
? formData.aiEntitiesInput
|
||||||
|
.split(',')
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const faq = formData.faqItems
|
||||||
|
.map(item => ({ question: item.question.trim(), answer: item.answer.trim() }))
|
||||||
|
.filter(item => item.question && item.answer);
|
||||||
return {
|
return {
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
slug: formData.slug,
|
slug: formData.slug,
|
||||||
@ -192,6 +235,10 @@ export default function AdminBlogPage() {
|
|||||||
metaDescription: formData.metaDescription || undefined,
|
metaDescription: formData.metaDescription || undefined,
|
||||||
primaryKeyword: formData.primaryKeyword || undefined,
|
primaryKeyword: formData.primaryKeyword || undefined,
|
||||||
secondaryKeywords,
|
secondaryKeywords,
|
||||||
|
aiSummary: formData.aiSummary.trim() ? formData.aiSummary : null,
|
||||||
|
faq,
|
||||||
|
keyTakeaways,
|
||||||
|
aiEntities,
|
||||||
scheduledAt: scheduledAt || undefined,
|
scheduledAt: scheduledAt || undefined,
|
||||||
...(withStatus ? { status: withStatus } : {}),
|
...(withStatus ? { status: withStatus } : {}),
|
||||||
};
|
};
|
||||||
@ -262,9 +309,11 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadCoverFile = async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
if (!file.type.startsWith('image/')) {
|
||||||
if (!file) return;
|
alert('Veuillez sélectionner une image');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
alert('Image trop volumineuse (max 5 Mo)');
|
alert('Image trop volumineuse (max 5 Mo)');
|
||||||
return;
|
return;
|
||||||
@ -274,8 +323,9 @@ export default function AdminBlogPage() {
|
|||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('image', file);
|
fd.append('image', file);
|
||||||
|
// Dedicated endpoint: auto-crops to the public 16:9 card ratio.
|
||||||
const result = await upload<{ url: string; filename: string }>(
|
const result = await upload<{ url: string; filename: string }>(
|
||||||
'/api/v1/admin/blog/images',
|
'/api/v1/admin/blog/cover-images',
|
||||||
fd
|
fd
|
||||||
);
|
);
|
||||||
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
|
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
|
||||||
@ -288,6 +338,73 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) await uploadCoverFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverDrop = async (e: React.DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingCover(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) await uploadCoverFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// FAQ (GEO) helpers
|
||||||
|
const addFaqItem = () => {
|
||||||
|
setFormData(prev => ({ ...prev, faqItems: [...prev.faqItems, { question: '', answer: '' }] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFaqItem = (index: number, field: 'question' | 'answer', value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
faqItems: prev.faqItems.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFaqItem = (index: number) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
faqItems: prev.faqItems.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (post: BlogPost) => {
|
||||||
|
setActioningId(post.id);
|
||||||
|
try {
|
||||||
|
await duplicateBlogPost(post.id);
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la duplication');
|
||||||
|
} finally {
|
||||||
|
setActioningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (post: BlogPost) => {
|
||||||
|
setActioningId(post.id);
|
||||||
|
try {
|
||||||
|
await restoreBlogPost(post.id);
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la restauration');
|
||||||
|
} finally {
|
||||||
|
setActioningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermanentDelete = async () => {
|
||||||
|
if (!selectedPost) return;
|
||||||
|
try {
|
||||||
|
await permanentlyDeleteBlogPost(selectedPost.id);
|
||||||
|
await fetchPosts();
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setSelectedPost(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la suppression définitive');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormData(EMPTY_FORM);
|
setFormData(EMPTY_FORM);
|
||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
@ -295,6 +412,7 @@ export default function AdminBlogPage() {
|
|||||||
setEditStatus('draft');
|
setEditStatus('draft');
|
||||||
setSelectedPost(null);
|
setSelectedPost(null);
|
||||||
setSeoOpen(false);
|
setSeoOpen(false);
|
||||||
|
setGeoOpen(false);
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -313,11 +431,23 @@ export default function AdminBlogPage() {
|
|||||||
metaDescription: post.metaDescription ?? '',
|
metaDescription: post.metaDescription ?? '',
|
||||||
primaryKeyword: post.primaryKeyword ?? '',
|
primaryKeyword: post.primaryKeyword ?? '',
|
||||||
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
|
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
|
||||||
|
aiSummary: post.aiSummary ?? '',
|
||||||
|
faqItems: (post.faq ?? []).map(item => ({ ...item })),
|
||||||
|
keyTakeawaysInput: (post.keyTakeaways ?? []).join('\n'),
|
||||||
|
aiEntitiesInput: (post.aiEntities ?? []).join(', '),
|
||||||
});
|
});
|
||||||
setTagsInput(post.tags.join(', '));
|
setTagsInput(post.tags.join(', '));
|
||||||
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
|
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
|
||||||
setEditStatus(post.status);
|
setEditStatus(post.status);
|
||||||
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
|
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
|
||||||
|
setGeoOpen(
|
||||||
|
!!(
|
||||||
|
post.aiSummary ||
|
||||||
|
(post.faq && post.faq.length) ||
|
||||||
|
(post.keyTakeaways && post.keyTakeaways.length) ||
|
||||||
|
(post.aiEntities && post.aiEntities.length)
|
||||||
|
)
|
||||||
|
);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -331,6 +461,7 @@ export default function AdminBlogPage() {
|
|||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
setScheduledAt('');
|
setScheduledAt('');
|
||||||
setSeoOpen(false);
|
setSeoOpen(false);
|
||||||
|
setGeoOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Explicit close (X / Retour / after save): reset UI and pop the history
|
// Explicit close (X / Retour / after save): reset UI and pop the history
|
||||||
@ -407,10 +538,36 @@ export default function AdminBlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status filters */}
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: 'all', label: 'Tous' },
|
||||||
|
{ value: 'published', label: 'Publiés' },
|
||||||
|
{ value: 'draft', label: 'Brouillons' },
|
||||||
|
{ value: 'scheduled', label: 'Planifiés' },
|
||||||
|
{ value: 'trash', label: 'Corbeille' },
|
||||||
|
] as const
|
||||||
|
).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => setViewFilter(tab.value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
viewFilter === tab.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.value === 'trash' && <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-6">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-4">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
@ -435,14 +592,16 @@ export default function AdminBlogPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{posts.length === 0 ? (
|
{visiblePosts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
Aucun article. Créez votre premier article !
|
{viewFilter === 'trash'
|
||||||
|
? 'La corbeille est vide.'
|
||||||
|
: 'Aucun article. Créez votre premier article !'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
posts.map(post => (
|
visiblePosts.map(post => (
|
||||||
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
@ -498,6 +657,33 @@ export default function AdminBlogPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end space-x-1">
|
<div className="flex items-center justify-end space-x-1">
|
||||||
|
{viewFilter === 'trash' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(post)}
|
||||||
|
disabled={actioningId === post.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Restaurer"
|
||||||
|
>
|
||||||
|
{actioningId === post.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPost(post);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
title="Supprimer définitivement"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{(post.status === 'published' || post.status === 'scheduled') && (
|
{(post.status === 'published' || post.status === 'scheduled') && (
|
||||||
<Link href={`/blog/${post.slug}`} target="_blank">
|
<Link href={`/blog/${post.slug}`} target="_blank">
|
||||||
<button
|
<button
|
||||||
@ -530,6 +716,18 @@ export default function AdminBlogPage() {
|
|||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDuplicate(post)}
|
||||||
|
disabled={actioningId === post.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Dupliquer"
|
||||||
|
>
|
||||||
|
{actioningId === post.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(post)}
|
onClick={() => openEdit(post)}
|
||||||
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
@ -543,10 +741,12 @@ export default function AdminBlogPage() {
|
|||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
title="Supprimer"
|
title="Mettre à la corbeille"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -752,6 +952,144 @@ export default function AdminBlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GEO / AI Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGeoOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
GEO — Optimisation pour l'IA
|
||||||
|
</span>
|
||||||
|
{(formData.aiSummary ||
|
||||||
|
formData.faqItems.length > 0 ||
|
||||||
|
formData.keyTakeawaysInput ||
|
||||||
|
formData.aiEntitiesInput) && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{geoOpen ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{geoOpen && (
|
||||||
|
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-400 mt-4">
|
||||||
|
Ces champs aident les moteurs IA (ChatGPT, Perplexity…) et le référencement
|
||||||
|
structuré à comprendre et citer votre article.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* AI summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Résumé IA (TL;DR)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={formData.aiSummary}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, aiSummary: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Un résumé concis de l'article, optimisé pour être cité par une IA."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
FAQ (questions / réponses)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addFaqItem}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.faqItems.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">Aucune question ajoutée.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.faqItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.question}
|
||||||
|
onChange={e => updateFaqItem(index, 'question', e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="Question"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFaqItem(index)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600"
|
||||||
|
title="Retirer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={item.answer}
|
||||||
|
onChange={e => updateFaqItem(index, 'answer', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Réponse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key takeaways */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Points clés à retenir{' '}
|
||||||
|
<span className="text-gray-400 font-normal">(un par ligne)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={formData.keyTakeawaysInput}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData(prev => ({ ...prev, keyTakeawaysInput: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder={'Le fret LCL est idéal pour les petits volumes\nComparez plusieurs transporteurs'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI entities */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Entités / sujets{' '}
|
||||||
|
<span className="text-gray-400 font-normal">(virgule)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.aiEntitiesInput}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData(prev => ({ ...prev, aiEntitiesInput: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="ex: fret maritime, LCL, incoterms, Le Havre"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar — 1/3 */}
|
{/* Sidebar — 1/3 */}
|
||||||
@ -843,15 +1181,27 @@ export default function AdminBlogPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => coverInputRef.current?.click()}
|
onClick={() => coverInputRef.current?.click()}
|
||||||
disabled={uploadingCover}
|
disabled={uploadingCover}
|
||||||
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center gap-2 text-gray-400 hover:border-blue-400 hover:text-blue-500 transition-colors disabled:opacity-50"
|
onDragOver={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingCover(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDraggingCover(false)}
|
||||||
|
onDrop={handleCoverDrop}
|
||||||
|
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
|
||||||
|
isDraggingCover
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
|
: 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{uploadingCover ? (
|
{uploadingCover ? (
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ImageIcon className="w-6 h-6" />
|
<ImageIcon className="w-6 h-6" />
|
||||||
<span className="text-sm">Cliquer pour uploader</span>
|
<span className="text-sm">
|
||||||
<span className="text-xs">JPG, PNG, WebP — max 5 Mo</span>
|
{isDraggingCover ? "Déposez l'image ici" : 'Glisser-déposer ou cliquer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">JPG, PNG, WebP — recadrée en 16:9</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -949,10 +1299,22 @@ export default function AdminBlogPage() {
|
|||||||
{showDeleteConfirm && selectedPost && (
|
{showDeleteConfirm && selectedPost && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l'article</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{viewFilter === 'trash' ? 'Supprimer définitivement' : "Mettre à la corbeille"}
|
||||||
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Êtes-vous sûr de vouloir supprimer <strong>« {selectedPost.title} »</strong>{' '}
|
{viewFilter === 'trash' ? (
|
||||||
? Cette action est irréversible.
|
<>
|
||||||
|
Êtes-vous sûr de vouloir supprimer définitivement{' '}
|
||||||
|
<strong>« {selectedPost.title} »</strong> ? Cette action est
|
||||||
|
irréversible.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>« {selectedPost.title} »</strong> sera déplacé vers la corbeille.
|
||||||
|
Vous pourrez le restaurer plus tard.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
@ -965,10 +1327,10 @@ export default function AdminBlogPage() {
|
|||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={viewFilter === 'trash' ? handlePermanentDelete : handleDelete}
|
||||||
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Supprimer
|
{viewFilter === 'trash' ? 'Supprimer définitivement' : 'Mettre à la corbeille'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -214,6 +214,16 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
|||||||
{/* Content + Sidebar */}
|
{/* Content + Sidebar */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||||
|
{/* En bref (AI summary / TL;DR) */}
|
||||||
|
{post.aiSummary && (
|
||||||
|
<div className="mb-10 rounded-2xl border border-brand-turquoise/30 bg-brand-turquoise/5 p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-brand-turquoise mb-2">
|
||||||
|
En bref
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 leading-relaxed">{post.aiSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
|
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
|
||||||
{/* Article body */}
|
{/* Article body */}
|
||||||
<motion.article
|
<motion.article
|
||||||
@ -245,6 +255,44 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* À retenir (key takeaways) */}
|
||||||
|
{post.keyTakeaways && post.keyTakeaways.length > 0 && (
|
||||||
|
<div className="mt-12 rounded-2xl border border-gray-200 bg-gray-50 p-6">
|
||||||
|
<h2 className="text-lg font-bold text-brand-navy mb-4">À retenir</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{post.keyTakeaways.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3 text-gray-700">
|
||||||
|
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-brand-turquoise flex-shrink-0" />
|
||||||
|
<span className="leading-relaxed">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
{post.faq && post.faq.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-brand-navy mb-6">Questions fréquentes</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{post.faq.map((item, i) => (
|
||||||
|
<details
|
||||||
|
key={i}
|
||||||
|
className="group rounded-xl border border-gray-200 bg-white p-5 open:shadow-sm"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer font-semibold text-brand-navy list-none flex items-center justify-between gap-4">
|
||||||
|
<span>{item.question}</span>
|
||||||
|
<span className="text-brand-turquoise transition-transform group-open:rotate-45 text-xl leading-none">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<p className="mt-3 text-gray-700 leading-relaxed">{item.answer}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{post.tags.length > 0 && (
|
{post.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
|
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import BlogPostContent from './BlogPostContent';
|
|||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
async function fetchPostMeta(slug: string) {
|
interface BlogFaqItem {
|
||||||
try {
|
question: string;
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
answer: string;
|
||||||
cache: 'no-store',
|
}
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
interface BlogPostMeta {
|
||||||
return res.json() as Promise<{
|
|
||||||
title: string;
|
title: string;
|
||||||
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
@ -20,12 +20,62 @@ async function fetchPostMeta(slug: string) {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>;
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPostMeta(slug: string): Promise<BlogPostMeta | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as BlogPostMeta;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildJsonLd(post: BlogPostMeta, slug: string) {
|
||||||
|
const coverImage = post.coverImageUrl
|
||||||
|
? post.coverImageUrl.startsWith('http')
|
||||||
|
? post.coverImageUrl
|
||||||
|
: `${API_BASE_URL}${post.coverImageUrl}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const article = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.aiSummary || post.excerpt,
|
||||||
|
image: coverImage ? [coverImage] : undefined,
|
||||||
|
datePublished: post.publishedAt,
|
||||||
|
dateModified: post.updatedAt,
|
||||||
|
author: { '@type': 'Person', name: post.authorName },
|
||||||
|
publisher: { '@type': 'Organization', name: 'Xpeditis' },
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': `https://xpeditis.com/blog/${slug}` },
|
||||||
|
keywords: [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', '),
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqPage =
|
||||||
|
post.faq && post.faq.length > 0
|
||||||
|
? {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: post.faq.map(item => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { article, faqPage };
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@ -72,10 +122,31 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogPostPage({
|
export default async function BlogPostPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { slug: string; locale: string };
|
params: { slug: string; locale: string };
|
||||||
}) {
|
}) {
|
||||||
return <BlogPostContent slug={params.slug} />;
|
const post = await fetchPostMeta(params.slug);
|
||||||
|
const jsonLd = post ? buildJsonLd(post, params.slug) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.article) }}
|
||||||
|
/>
|
||||||
|
{jsonLd.faqPage && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.faqPage) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<BlogPostContent slug={params.slug} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,12 +197,22 @@ export async function getOrganizationDocuments(organizationId: string): Promise<
|
|||||||
|
|
||||||
// ==================== BLOG ====================
|
// ==================== BLOG ====================
|
||||||
|
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlogPostSeoFields {
|
export interface BlogPostSeoFields {
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords?: string[];
|
secondaryKeywords?: string[];
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
// GEO (Generative Engine Optimisation)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
aiEntities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
||||||
@ -233,11 +243,13 @@ export async function getAllBlogPosts(params?: {
|
|||||||
status?: BlogPostStatus;
|
status?: BlogPostStatus;
|
||||||
category?: BlogPostCategory;
|
category?: BlogPostCategory;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
trashed?: boolean;
|
||||||
}): Promise<{ posts: BlogPost[]; total: number }> {
|
}): Promise<{ posts: BlogPost[]; total: number }> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
if (params?.category) query.set('category', params.category);
|
if (params?.category) query.set('category', params.category);
|
||||||
if (params?.search) query.set('search', params.search);
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.trashed) query.set('trashed', 'true');
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return get(`/api/v1/admin/blog${qs ? `?${qs}` : ''}`);
|
return get(`/api/v1/admin/blog${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@ -250,6 +262,22 @@ export async function updateBlogPost(id: string, data: UpdateBlogPostRequest): P
|
|||||||
return patch<BlogPost>(`/api/v1/admin/blog/${id}`, data);
|
return patch<BlogPost>(`/api/v1/admin/blog/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a post to the trash (soft delete). */
|
||||||
export async function deleteBlogPost(id: string): Promise<void> {
|
export async function deleteBlogPost(id: string): Promise<void> {
|
||||||
return del<void>(`/api/v1/admin/blog/${id}`);
|
return del<void>(`/api/v1/admin/blog/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore a post from the trash. */
|
||||||
|
export async function restoreBlogPost(id: string): Promise<BlogPost> {
|
||||||
|
return post<BlogPost>(`/api/v1/admin/blog/${id}/restore`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently delete a post from the trash. */
|
||||||
|
export async function permanentlyDeleteBlogPost(id: string): Promise<void> {
|
||||||
|
return del<void>(`/api/v1/admin/blog/${id}/permanent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate a post as a new draft. */
|
||||||
|
export async function duplicateBlogPost(id: string): Promise<BlogPost> {
|
||||||
|
return post<BlogPost>(`/api/v1/admin/blog/${id}/duplicate`, {});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,11 @@ import { get } from './client';
|
|||||||
export type BlogPostStatus = 'draft' | 'scheduled' | 'published' | 'archived';
|
export type BlogPostStatus = 'draft' | 'scheduled' | 'published' | 'archived';
|
||||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||||
|
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlogPost {
|
export interface BlogPost {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -20,6 +25,11 @@ export interface BlogPost {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords: string[];
|
secondaryKeywords: string[];
|
||||||
|
// GEO (Generative Engine Optimisation)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
aiEntities?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user