diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 5329e6a..898c16a 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/package.json b/package.json index bbae30e..6628653 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "tailwind:viewer": "tailwind-config-viewer -o" }, "dependencies": { - "@contentful/rich-text-from-markdown": "^16.0.1", + "@contentful/rich-text-from-markdown": "^16.1.8", "@contentful/rich-text-html-renderer": "^17.0.1", "@contentful/rich-text-plain-text-renderer": "^17.0.1", "@contentful/rich-text-types": "^17.0.1", @@ -22,15 +22,28 @@ "@heroicons/react": "^2.2.0", "@react-leaflet/core": "^3.0.0", "@tailwindcss/line-clamp": "^0.4.4", + "@tiptap/extension-color": "^3.22.3", + "@tiptap/extension-image": "^3.20.5", + "@tiptap/extension-link": "^3.20.5", + "@tiptap/extension-placeholder": "^3.20.5", + "@tiptap/extension-text-align": "^3.20.5", + "@tiptap/extension-text-style": "^3.22.3", + "@tiptap/extension-underline": "^3.20.5", + "@tiptap/extensions": "^3.22.3", + "@tiptap/pm": "^3.20.5", + "@tiptap/react": "^3.20.5", + "@tiptap/starter-kit": "^3.20.5", "@vercel/analytics": "^1.5.0", "algoliasearch": "^5.28.0", "classnames": "^2.5.1", "contentful": "^11.7.2", "contentful-management": "^11.54.2", + "hast-util-to-mdast": "^10.1.2", "instantsearch.js": "^4.79.0", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "next": "15.5.7", + "next-auth": "^4.24.13", "next-seo": "^6.8.0", "nodemailer": "^7.0.3", "nodemailer-react": "^1.0.2", @@ -45,7 +58,11 @@ "react-instantsearch-nextjs": "^0.5.0", "react-json-view": "^1.21.3", "react-leaflet": "^5.0.0", - "react-share": "^5.2.2" + "react-share": "^5.2.2", + "rehype-parse": "^9.0.1", + "remark-stringify": "^11.0.0", + "tiptap-markdown": "^0.9.0", + "unified": "^11.0.5" }, "devDependencies": { "@eslint/compat": "^1.3.0", diff --git a/src/app/admin/fiches/[id]/editor/EditorMap.tsx b/src/app/admin/fiches/[id]/editor/EditorMap.tsx new file mode 100644 index 0000000..69d9dfb --- /dev/null +++ b/src/app/admin/fiches/[id]/editor/EditorMap.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect } from 'react' +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet' +import 'leaflet/dist/leaflet.css' +import L from 'leaflet' + +// Fix le bug des icônes Leaflet qui ne s'affichent pas en Next.js +const fixLeafletIcons = () => { + // eslint-disable-next-line no-underscore-dangle + delete (L.Icon.Default.prototype as any)._getIconUrl + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', + }) +} + +type Structure = { + id: string + nom: string + adresse: string + type: string + tel?: string + email?: string + latLon?: { lat: number; lon: number } +} + +export default function EditorMap({ structures }: { structures: Structure[] }) { + useEffect(() => { + fixLeafletIcons() + }, []) + + // Filtre les structures avec des coordonnées valides + const structuresAvecCoords = structures.filter( + (s) => s.latLon && s.latLon.lat !== 0 && s.latLon.lon !== 0, + ) + + // Centre de la carte sur Auvergne-Rhône-Alpes + const center: [number, number] = [45.5, 4.5] + + return ( +
+ {/* Carte */} +
+ + + {structuresAvecCoords.map((s) => ( + + +
+

{s.nom}

+

{s.type}

+

{s.adresse}

+ {s.tel &&

📞 {s.tel}

} + {s.email &&

✉️ {s.email}

} +
+
+
+ ))} +
+
+ + {/* Liste des structures — identique au vrai site */} +
+ {structures.map((s) => ( +
+

{s.nom}

+ + {s.type} + + {s.adresse && ( +

+ 📍 {s.adresse} +

+ )} + {s.tel && ( +

+ 📞 {s.tel} +

+ )} + {s.email && ( +

+ ✉️ {s.email} +

+ )} +
+ ))} + {structures.length === 0 && ( +

+ Sélectionnez des types de dispositif pour voir les structures associées. +

+ )} +
+
+ ) +} diff --git a/src/app/admin/fiches/[id]/editor/FicheEditorView.tsx b/src/app/admin/fiches/[id]/editor/FicheEditorView.tsx new file mode 100644 index 0000000..5754d52 --- /dev/null +++ b/src/app/admin/fiches/[id]/editor/FicheEditorView.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useEffect, useState } from 'react' +import { not } from 'ramda' +import { useEditor as useEditorCtx } from '@/components/admin/editor/EditorContext' +import { EditableField } from '@/components/admin/editor/EditableField' +import { HeaderFiche } from '@/components/Layout/HeaderFiche' +import { Container } from '@/components/Layout/Container' +import { FloatingButtons } from '@/components/FloatingButtons' +import { Prose } from '@/components/Prose' +import { SecondaryButton } from '@/components/Buttons' +import { StructuresList } from '@/components/Map/StructuresList' +import { Box } from '@/components/Layout/Box' +import { categories } from '@/data/categories' +import { getStructuresByTypes } from '@/services/contentful-management' + +const FICHE_FIELDS = { + titre: { + key: 'titre', + label: 'Titre', + type: 'text' as const, + hint: 'Titre principal affiché sur la fiche.', + }, + categorie: { + key: 'categorie', + label: 'Catégorie', + type: 'select' as const, + options: [ + { value: 'sante', label: 'Accès à la santé' }, + { value: 'besoins-primaires', label: 'Besoins primaires' }, + { value: 'social', label: 'Social' }, + { value: 'interpretariat', label: 'Interprétariat' }, + ], + }, + description: { + key: 'description', + label: 'Description courte', + type: 'textarea' as const, + maxLength: 280, + hint: 'Texte court de présentation.', + }, + tags: { + key: 'tags', + label: 'Tags', + type: 'text' as const, + hint: 'Séparés par des virgules.', + }, + resume: { + key: 'resume', + label: 'Résumé', + type: 'markdown' as const, + hint: 'Bloc affiché avant le bouton "Afficher les détails".', + }, + contenu: { + key: 'contenu', + label: 'Contenu détaillé', + type: 'markdown' as const, + hint: 'Bloc affiché après ouverture des détails.', + }, + typeDispositif: { + key: 'typeDispositif', + label: 'Types de dispositif', + type: 'checkboxGroup' as const, + options: [ + 'Accompagnement MNA', + "Association d'aide aux migrants", + 'Association LGBTQIA+', + 'Associations caritatives - Distribution Alimentaire', + "Associations d'accompagnement personnes en situation de prostitution", + 'CAARUD', + 'CADA', + 'CAES', + 'CD', + 'CEGIDD', + 'Centre de vaccination', + 'COREVIH', + 'CPH', + 'CPTS', + 'CSAPA', + 'Filières gérontologiques', + 'HUDA', + 'MDPH', + 'MSP', + 'OFII', + 'PASS', + 'PRAHDA', + 'Préfecture', + 'Réseaux polyvalents (tous âges et toutes pathologies)', + 'SIAO', + 'SPADA', + ].map((t) => ({ value: t, label: t })), + hint: 'Détermine les structures liées à la fiche.', + }, +} + +type PreviewLink = { + id: string + titre: string +} + +function PreviewLinksCard({ title, links }: { title: string; links: PreviewLink[] }) { + if (!links.length) { + return null + } + + return ( + + {links.map((link) => ( +
+ {link.titre} +
+ ))} +
+ ) +} + +export function FicheEditorView() { + const { values } = useEditorCtx() + const [showDetails, setShowDetails] = useState(false) + const [structures, setStructures] = useState([]) + + const typeDispositif = Array.isArray(values.typeDispositif) ? values.typeDispositif : [] + + useEffect(() => { + if (typeDispositif.length > 0) { + getStructuresByTypes(typeDispositif).then(setStructures) + } else { + setStructures([]) + } + }, [typeDispositif]) + + const categorie = values.categorie && categories[values.categorie as keyof typeof categories] + ? categories[values.categorie as keyof typeof categories] + : categories.sante + + const tags = typeof values.tags === 'string' + ? values.tags.split(',').map((tag: string) => tag.trim()).filter(Boolean) + : [] + + const outils = Array.isArray(values.outils) ? values.outils : [] + const patients = Array.isArray(values.patients) ? values.patients : [] + const pourEnSavoirPlus = Array.isArray(values.pourEnSavoirPlus) ? values.pourEnSavoirPlus : [] + + const fichePreview = { + titre: values.titre ?? '', + categorie: values.categorie ?? 'sante', + updatedAt: new Date().toISOString(), + } + + return ( +
+ + +
+ + + +
+ +

+ {values.titre || Titre non défini} +

+
+
+ + {tags.length > 0 && ( + +
+ {tags.map((tag: string) => ( + + {tag} + + ))} +
+
+ )} + +
+
+ + {values.resume ? ( + + ) : ( +
+

+ Cliquez ici pour rédiger le résumé +

+
+ )} +
+ + setShowDetails(not)} + > + {showDetails ? 'Masquer les détails' : 'Afficher les détails'} + + + {showDetails && ( + + {values.contenu ? ( + + ) : ( +
+

+ Cliquez ici pour rédiger le contenu détaillé +

+
+ )} +
+ )} +
+ +
+ + + {typeDispositif.length > 0 ? ( +
+ {typeDispositif.map((item: string) => ( + + {item} + + ))} +
+ ) : ( +

+ Cliquez pour sélectionner les types de dispositif +

+ )} +
+
+ + + +

+ {values.description || ( + Aucune description + )} +

+
+
+ + + + +
+ +
+ {structures.length > 0 ? : null} +
+
+
+
+ +
+ Mode édition : cliquez sur un élément pour le modifier dans le panneau latéral +
+
+ ) +} diff --git a/src/app/admin/fiches/[id]/editor/page.tsx b/src/app/admin/fiches/[id]/editor/page.tsx new file mode 100644 index 0000000..7e620df --- /dev/null +++ b/src/app/admin/fiches/[id]/editor/page.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useParams } from 'next/navigation' +import toast from 'react-hot-toast' +import { EditorProvider } from '@/components/admin/editor/EditorContext' +import { EditorToolbar } from '@/components/admin/editor/EditorToolbar' +import { InspectorPanel } from '@/components/admin/editor/InspectorPanel' +import { + getFicheById, + updateFicheInContentful, + publishFiche, +} from '@/services/contentful-management' +import { FicheEditorView } from './FicheEditorView' + +export default function FicheEditorPage() { + const params = useParams() + const id = params.id as string + + const [loading, setLoading] = useState(true) + const [initialValues, setInitialValues] = useState>({}) + const [isPublished, setIsPublished] = useState(false) + const [titre, setTitre] = useState('') + + useEffect(() => { + getFicheById(id).then((fiche) => { + setInitialValues({ + titre: fiche.titre, + slug: fiche.slug, + categorie: fiche.categorie, + description: fiche.description, + tags: fiche.tags.join(', '), + resume: fiche.resume, + contenu: fiche.contenu, + typeDispositif: fiche.typeDispositif, + illustrationId: fiche.illustrationId ?? '', + }) + setIsPublished(fiche.statut === 'published') + setTitre(fiche.titre) + setLoading(false) + }).catch(() => { + toast.error('Impossible de charger la fiche.') + }) + }, [id]) + + // Sauvegarde — reçoit les valeurs modifiées depuis l'EditorContext + const handleSave = useCallback(async (values: Record) => { + await updateFicheInContentful(id, { + titre: values.titre, + categorie: values.categorie, + description: values.description, + tags: values.tags.split(',').map((t: string) => t.trim()).filter(Boolean), + resume: values.resume, + contenu: values.contenu, + typeDispositif: values.typeDispositif ?? [], + }) + toast.success('Sauvegardé en brouillon.') + }, [id]) + + // Publication + const handlePublish = useCallback(async () => { + await publishFiche(id) + toast.success('Fiche publiée sur le site !') + setIsPublished(true) + }, [id]) + + if (loading) { + return ( +
+
+
+

Chargement de la fiche...

+
+
+ ) + } + + return ( + // EditorProvider = fournit le contexte à toute la page + + {/* Layout fixe plein écran */} +
+ + {/* Barre du haut */} + + + {/* Corps : preview + panneau */} +
+ + {/* Zone preview — scrollable */} +
+ +
+ + {/* Panneau latéral — largeur fixe */} +
+ +
+ +
+
+
+ ) +} diff --git a/src/app/admin/fiches/[id]/modifier/page.tsx b/src/app/admin/fiches/[id]/modifier/page.tsx new file mode 100644 index 0000000..aa40936 --- /dev/null +++ b/src/app/admin/fiches/[id]/modifier/page.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import toast from 'react-hot-toast' +import { AdminFicheForm, AdminFicheFields } from '@/components/admin/AdminFicheForm' +import { + getFicheById, + updateFicheInContentful, + publishFiche, +} from '@/services/contentful-management' + +export default function ModifierFichePage() { + const params = useParams() + const id = params.id as string + const router = useRouter() + + const [loading, setLoading] = useState(true) + const [defaultValues, setDefaultValues] = useState>() + const [illustrationUrl, setIllustrationUrl] = useState() + const [isPublished, setIsPublished] = useState(false) + const [nomFiche, setNomFiche] = useState('') + + useEffect(() => { + getFicheById(id).then((fiche) => { + setDefaultValues({ + titre: fiche.titre, + slug: fiche.slug, + categorie: fiche.categorie, + description: fiche.description, + tags: fiche.tags.join(', '), + resume: fiche.resume, + contenu: fiche.contenu, + typeDispositif: fiche.typeDispositif, + // Liens associés + outilsIds: fiche.outilsIds ?? [], + patientsIds: fiche.patientsIds ?? [], + pourEnSavoirPlusIds: fiche.pourEnSavoirPlusIds ?? [], + }) + setIllustrationUrl(fiche.illustrationUrl) + setIsPublished(fiche.statut === 'published') + setNomFiche(fiche.titre) + setLoading(false) + }).catch(() => { + toast.error('Impossible de charger la fiche.') + router.push('/admin/fiches') + }) + }, [id, router]) + + const handleSave = async (data: AdminFicheFields) => { + // Sauvegarde champs texte + await updateFicheInContentful(id, { + titre: data.titre, + categorie: data.categorie, + description: data.description, + tags: data.tags.split(',').map((t) => t.trim()).filter(Boolean), + resume: data.resume, + contenu: data.contenu, + typeDispositif: data.typeDispositif ?? [], + }) + + // Sauvegarde les liens associés + await updateFicheLiens(id, { + outils: data.outilsIds ?? [], + patients: data.patientsIds ?? [], + pourEnSavoirPlus: data.pourEnSavoirPlusIds ?? [], + }) + + toast.success('Fiche sauvegardée en brouillon.') + } + + const handlePublish = async () => { + await publishFiche(id) + toast.success('Fiche publiée !') + } + + if (loading) { + return ( +
+
+ Chargement de la fiche... +
+ ) + } + + return ( +
+ + ← Retour aux fiches + + +
+
+

Modifier la fiche

+

{nomFiche}

+
+
+ + {isPublished ? '● Publié' : '○ Brouillon'} + + {/* Lien vers l'éditeur visuel */} + + ✏️ Éditeur visuel + +
+
+ +
+ +
+
+ ) +} diff --git a/src/app/admin/fiches/[id]/preview/page.tsx b/src/app/admin/fiches/[id]/preview/page.tsx new file mode 100644 index 0000000..5d16fd0 --- /dev/null +++ b/src/app/admin/fiches/[id]/preview/page.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' +import toast from 'react-hot-toast' +import Link from 'next/link' +import { getStructureById, updateStructureInContentful, publishStructure } from '@/services/contentful-management' +import { AdminStructureFields } from '@/components/admin/AdminStructureForm' +import { types } from '@/data/structures_types' +import { PrimaryButton } from '@/components/Buttons' + +export default function StructurePreviewPage() { + const params = useParams() + const id = params.id as string + const router = useRouter() + const [loading, setLoading] = useState(true) + const [isPublished, setIsPublished] = useState(false) + + const { register, watch, reset, handleSubmit, formState: { isSubmitting } } = useForm() + + // watch() observe TOUS les champs en temps réel → alimente la preview + const formValues = watch() + + useEffect(() => { + getStructureById(id).then((s) => { + reset({ nom: s.nom, + organisation: s.organisation, + type: s.type, + adresse: s.adresse, + email: s.email, + tel: s.tel, + siteWeb: s.siteWeb, + description: s.description }) + setIsPublished(s.statut === 'published') + setLoading(false) + }) + }, [id, reset]) + + const onSubmit = async (data: AdminStructureFields) => { + try { + await updateStructureInContentful(id, { + ...data, + specialites: data.specialites + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + }) + toast.success('Sauvegardé en brouillon.') + router.refresh() + } catch (_e) { + toast.error('Erreur lors de la sauvegarde.') + } + } + + const handlePublish = async () => { + try { + await publishStructure(id) + toast.success('Structure publiée !') + setIsPublished(true) + } catch (_e) { + toast.error('Erreur de publication.') + } + } + + if (loading) return
Chargement...
+ + const structureTypes = Object.keys(types) + + return ( + // Layout split : 2 colonnes égales +
+ + {/* ── COLONNE GAUCHE : formulaire ── */} +
+ + {/* Barre d'actions */} +
+ + ← Retour + +
+ + Sauvegarder + + {!isPublished && ( + + )} +
+
+ + {/* Champs */} +
+ {[ + { name: 'nom', label: 'Nom', type: 'text' }, + { name: 'organisation', label: 'Organisation', type: 'text' }, + { name: 'adresse', label: 'Adresse', type: 'text' }, + { name: 'email', label: 'Email', type: 'email' }, + { name: 'tel', label: 'Téléphone', type: 'tel' }, + { name: 'siteWeb', label: 'Site web', type: 'url' }, + ].map(({ name, label, type }) => ( +
+ + +
+ ))} + +
+ + +
+ +
+ +