diff --git a/.kontinuous/env/dev/templates/www.configmap.yaml b/.kontinuous/env/dev/templates/www.configmap.yaml index f944770de..df8dcceff 100644 --- a/.kontinuous/env/dev/templates/www.configmap.yaml +++ b/.kontinuous/env/dev/templates/www.configmap.yaml @@ -7,6 +7,3 @@ data: MATOMO_URL: "https://matomo.fabrique.social.gouv.fr/" NODE_ENV: "production" URL_EXPORT: "http://export" - BUCKET_PUBLIC_ENDPOINT: "https://cdtn-dev-public.s3.gra.io.cloud.ovh.net" - BUCKET_DEFAULT_FOLDER: "default" - BUCKET_DRAFT_FOLDER: "draft" diff --git a/.kontinuous/env/dev/values.yaml b/.kontinuous/env/dev/values.yaml index 4ec6da5b2..5d6bbb78e 100644 --- a/.kontinuous/env/dev/values.yaml +++ b/.kontinuous/env/dev/values.yaml @@ -4,6 +4,9 @@ jobs: with: buildArgs: NEXT_PUBLIC_BASE_PATH: https://www-{{.Values.global.host}} + NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT: "https://cdtn-dev-public.s3.gra.io.cloud.ovh.net" + NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER: "default" + NEXT_PUBLIC_BUCKET_DRAFT_FOLDER: "draft" post-restore: ~needs: [ pg, hasura ] use: psql diff --git a/.kontinuous/env/preprod/templates/www.configmap.yaml b/.kontinuous/env/preprod/templates/www.configmap.yaml index f944770de..df8dcceff 100644 --- a/.kontinuous/env/preprod/templates/www.configmap.yaml +++ b/.kontinuous/env/preprod/templates/www.configmap.yaml @@ -7,6 +7,3 @@ data: MATOMO_URL: "https://matomo.fabrique.social.gouv.fr/" NODE_ENV: "production" URL_EXPORT: "http://export" - BUCKET_PUBLIC_ENDPOINT: "https://cdtn-dev-public.s3.gra.io.cloud.ovh.net" - BUCKET_DEFAULT_FOLDER: "default" - BUCKET_DRAFT_FOLDER: "draft" diff --git a/.kontinuous/env/preprod/values.yaml b/.kontinuous/env/preprod/values.yaml index f9b64d9e4..26a229461 100644 --- a/.kontinuous/env/preprod/values.yaml +++ b/.kontinuous/env/preprod/values.yaml @@ -4,6 +4,9 @@ jobs: with: buildArgs: NEXT_PUBLIC_BASE_PATH: https://cdtn-admin-preprod.ovh.fabrique.social.gouv.fr + NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT: "https://cdtn-dev-public.s3.gra.io.cloud.ovh.net" + NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER: "default" + NEXT_PUBLIC_BUCKET_DRAFT_FOLDER: "draft" post-restore: ~needs: [ pg, hasura ] use: psql diff --git a/.kontinuous/env/prod/templates/www.configmap.yaml b/.kontinuous/env/prod/templates/www.configmap.yaml index e13dd7931..afcbf08c2 100644 --- a/.kontinuous/env/prod/templates/www.configmap.yaml +++ b/.kontinuous/env/prod/templates/www.configmap.yaml @@ -9,6 +9,3 @@ data: NODE_ENV: "production" PRODUCTION: "true" URL_EXPORT: "http://export" - BUCKET_PUBLIC_ENDPOINT: "https://cdtn-prod-public.s3.gra.io.cloud.ovh.net" - BUCKET_DEFAULT_FOLDER: "default" - BUCKET_DRAFT_FOLDER: "draft" diff --git a/.kontinuous/env/prod/values.yaml b/.kontinuous/env/prod/values.yaml index ed6a32e7e..d01bc2d81 100644 --- a/.kontinuous/env/prod/values.yaml +++ b/.kontinuous/env/prod/values.yaml @@ -4,6 +4,9 @@ jobs: with: buildArgs: NEXT_PUBLIC_BASE_PATH: https://cdtn-admin.fabrique.social.gouv.fr + NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT: "https://cdtn-prod-public.s3.gra.io.cloud.ovh.net" + NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER: "default" + NEXT_PUBLIC_BUCKET_DRAFT_FOLDER: "draft" www: autoscale: diff --git a/targets/export-elasticsearch/src/server.ts b/targets/export-elasticsearch/src/server.ts index efd5c0292..283555c3d 100644 --- a/targets/export-elasticsearch/src/server.ts +++ b/targets/export-elasticsearch/src/server.ts @@ -44,7 +44,7 @@ rootContainer .toConstantValue(process.env.BUCKET_SECRET_KEY ?? ""); rootContainer .bind(S3Parameters.BUCKET_DRAFT_FOLDER) - .toConstantValue(process.env.BUCKET_DRAFT_FOLDER ?? `draft`); + .toConstantValue(process.env.NEXT_PUBLIC_BUCKET_DRAFT_FOLDER ?? `draft`); rootContainer .bind(S3Parameters.BUCKET_PUBLISHED_FOLDER) .toConstantValue(process.env.BUCKET_PUBLISHED_FOLDER ?? `published`); @@ -53,7 +53,7 @@ rootContainer .toConstantValue(process.env.BUCKET_PREVIEW_FOLDER ?? `preview`); rootContainer .bind(S3Parameters.BUCKET_DEFAULT_FOLDER) - .toConstantValue(process.env.BUCKET_DEFAULT_FOLDER ?? `default`); + .toConstantValue(process.env.NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER ?? `default`); /* REPOSITORIES */ rootContainer.bind(getName(S3Repository)).to(S3Repository); rootContainer diff --git a/targets/frontend/Dockerfile b/targets/frontend/Dockerfile index 0cd95fd16..a26f7384e 100644 --- a/targets/frontend/Dockerfile +++ b/targets/frontend/Dockerfile @@ -25,6 +25,12 @@ FROM deps as dist ARG NEXT_PUBLIC_BASE_PATH ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH +ARG NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT +ENV NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT=$NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT +ARG NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER +ENV NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER=$NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER +ARG NEXT_PUBLIC_BUCKET_DRAFT_FOLDER +ENV NEXT_PUBLIC_BUCKET_DRAFT_FOLDER=$NEXT_PUBLIC_BUCKET_DRAFT_FOLDER COPY --from=build-types /app/shared/types /app/shared/types COPY --from=build-utils /app/shared/utils /app/shared/utils diff --git a/targets/frontend/src/components/forms/EditionField/Editor.tsx b/targets/frontend/src/components/forms/EditionField/Editor.tsx index 5a26884c7..4853688a8 100644 --- a/targets/frontend/src/components/forms/EditionField/Editor.tsx +++ b/targets/frontend/src/components/forms/EditionField/Editor.tsx @@ -17,7 +17,18 @@ import { DetailsSummary } from "@tiptap-pro/extension-details-summary"; import { DetailsContent } from "@tiptap-pro/extension-details-content"; import { Placeholder } from "@tiptap/extension-placeholder"; import { Link } from "@tiptap/extension-link"; -import { Alert, Title } from "./extensions"; +import { Alert, Infographic, Title } from "./extensions"; +import { MenuInfographic } from "./MenuInfographic"; +import { + Button, + DialogActions, + DialogContentText, + TextField, +} from "@mui/material"; +import DialogTitle from "@mui/material/DialogTitle"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import { NodeSelection } from "@tiptap/pm/state"; export type EditorProps = { label: string; @@ -25,20 +36,53 @@ export type EditorProps = { onUpdate: (content: string) => void; disabled?: boolean; isError?: boolean; + infographicBaseUrl: string; }; const emptyHtml = "

"; +type ModeCreation = { + mode: 0; +}; +const Creation: ModeCreation = { mode: 0 }; + +type ModeEdition = { + mode: 1; + infoName: string; + pdfName: string; + pdfSize: string; +}; +const Edition = ( + infoName: string, + pdfName: string, + pdfSize: string +): ModeEdition => ({ + mode: 1, + infoName, + pdfName, + pdfSize, +}); + +type ModeHide = { + mode: -1; +}; +const Hide: ModeHide = { mode: -1 }; + +type Mode = ModeEdition | ModeCreation | ModeHide; + export const Editor = ({ label, content, onUpdate, disabled, + infographicBaseUrl, isError = false, }: EditorProps) => { const [currentContent, setCurrentContent] = useState(content); const [focus, setFocus] = useState(false); const [isClient, setIsClient] = useState(false); + const [infographicModal, setInfographicModal] = useState(Hide); + const editor = useEditor({ content, editable: !disabled, @@ -75,6 +119,9 @@ export const Editor = ({ }), Alert, Title, + Infographic.configure({ + baseUrl: infographicBaseUrl, + }), ], onUpdate: ({ editor }) => { const html = editor.getHTML(); @@ -98,6 +145,36 @@ export const Editor = ({ editor?.setOptions({ editable: !disabled }); }, [disabled]); + useEffect(() => { + // We need to focus on the infographic to edit it + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + + if ( + target.tagName === "IMG" && + target.closest(".infographic") && + editor + ) { + const pos = editor.view.posAtDOM( + target.closest(".infographic") as HTMLElement, + 0 + ); + + editor.view.dispatch( + editor.state.tr.setSelection( + NodeSelection.create(editor.state.doc, pos) + ) + ); + editor.commands.focus(); + } + }; + + document.addEventListener("click", handleClick); + return () => { + document.removeEventListener("click", handleClick); + }; + }, [editor]); + return ( <> {isClient && ( @@ -109,8 +186,28 @@ export const Editor = ({ htmlFor={label} > - + { + setInfographicModal(Creation); + }} + /> + { + const node = editor?.state.selection.$from.node(); + if (node?.type.name === "infographic") { + const dataInfo = node.attrs.infoName; + const dataPdf = node.attrs.pdfName; + const dataPdfSize = node.attrs.pdfSize; + setInfographicModal(Edition(dataInfo, dataPdf, dataPdfSize)); + } + }} + onDelete={() => { + editor?.commands.removeInfographic(); + }} + /> )} + { + setInfographicModal(Hide); + }} + PaperProps={{ + component: "form", + onSubmit: (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const { infoName, pdfName, pdfSize } = Object.fromEntries( + (formData as any).entries() + ); + if (infographicModal.mode === Creation.mode) { + editor + ?.chain() + .focus() + .setInfographic(infoName, pdfName, pdfSize) + .run(); + } else { + editor?.commands.updateInfographicSrc(infoName, pdfName, pdfSize); + } + setInfographicModal(Hide); + }, + }} + > + Infographie + + + Veuillez renseigner les informations suivantes pour ajouter une + infographie au document + + + + + + + + + + ); }; @@ -170,6 +358,10 @@ const StyledEditorContent = styled(EditorContent)(() => { backgroundColor: fr.colors.decisions.background.contrast.info.active, borderRadius: "0.6rem", }, + ".infographic": { + marginBottom: "1.6rem", + color: fr.colors.decisions.text.default, + }, li: { p: { margin: "0", diff --git a/targets/frontend/src/components/forms/EditionField/MenuInfographic.tsx b/targets/frontend/src/components/forms/EditionField/MenuInfographic.tsx new file mode 100644 index 000000000..378093433 --- /dev/null +++ b/targets/frontend/src/components/forms/EditionField/MenuInfographic.tsx @@ -0,0 +1,60 @@ +import { Editor, FloatingMenu } from "@tiptap/react"; +import Delete from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import { styled } from "@mui/system"; + +export const MenuInfographic = ({ + editor, + onEdit, + onDelete, +}: { + editor: Editor | null; + onEdit: () => void; + onDelete: () => void; +}) => { + return editor ? ( + { + return ( + editor?.state.selection.$from.node()?.type.name === "infographic" && + state.selection.content().content.size > 0 && + editor.isActive("infographic") + ); + }} + > + + + + ) : ( + <> + ); +}; + +const InfographicFloatingMenu = styled(FloatingMenu)` + display: flex; + background-color: #0d0d0d; + padding: 0.2rem; + border-radius: 0.5rem; + + button { + border: none; + background: none; + font-size: 0.85rem; + font-weight: 500; + padding: 0 0.2rem; + opacity: 0.6; + color: #fff; + + &:hover, + &.is-active { + opacity: 1; + } + } +`; diff --git a/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx b/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx index c6b4f3ffe..3ae405b4d 100644 --- a/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx +++ b/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx @@ -6,10 +6,10 @@ import { } from "@tiptap/react"; import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered"; +import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate"; import GridOnIcon from "@mui/icons-material/GridOn"; import StorageIcon from "@mui/icons-material/Storage"; import { styled } from "@mui/system"; -import InfoIcon from "@mui/icons-material/Info"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; const tableHTML = ` @@ -25,7 +25,13 @@ const tableHTML = ` `; -export const MenuSpecial = ({ editor }: { editor: Editor | null }) => { +export const MenuSpecial = ({ + editor, + onNewInfographic, +}: { + editor: Editor | null; + onNewInfographic: (editor: Editor) => void; +}) => { const getTextContent = (node: ProseMirrorNode) => { if (editor) { return getText(node, { @@ -110,13 +116,13 @@ export const MenuSpecial = ({ editor }: { editor: Editor | null }) => { ) : ( diff --git a/targets/frontend/src/components/forms/EditionField/extensions/Alert.ts b/targets/frontend/src/components/forms/EditionField/extensions/Alert.ts index 4e75bb67c..62d36314e 100644 --- a/targets/frontend/src/components/forms/EditionField/extensions/Alert.ts +++ b/targets/frontend/src/components/forms/EditionField/extensions/Alert.ts @@ -26,7 +26,7 @@ export const Alert = Node.create({ group: "block", parseHTML() { - return [{ tag: "div" }]; + return [{ tag: "div.alert" }]; }, renderHTML({ HTMLAttributes }) { diff --git a/targets/frontend/src/components/forms/EditionField/extensions/Infographic.ts b/targets/frontend/src/components/forms/EditionField/extensions/Infographic.ts new file mode 100644 index 000000000..6f4fbcc1f --- /dev/null +++ b/targets/frontend/src/components/forms/EditionField/extensions/Infographic.ts @@ -0,0 +1,186 @@ +import { Node } from "@tiptap/core"; + +export interface InfographicOptions { + baseUrl: string; +} + +declare module "@tiptap/core" { + interface Commands { + infographic: { + setInfographic: ( + infoName: string, + pdfName: string, + sizePdf: string + ) => ReturnType; + updateInfographicSrc: ( + newInfoName: string, + newPdfName: string, + newPdfSize: string + ) => ReturnType; + removeInfographic: () => ReturnType; + }; + } +} + +export const Infographic = Node.create({ + name: "infographic", + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + baseUrl: "", + }; + }, + + addAttributes() { + return { + infoName: { + parseHTML: (element) => + element.querySelector("img")?.getAttribute("data-infographic"), + renderHTML: (attributes) => { + return { "data-infographic": attributes.infoName }; + }, + }, + pdfName: { + parseHTML: (element) => + element.querySelector("div.infographic")?.getAttribute("data-pdf"), + renderHTML: (attributes) => { + return { "data-pdf": attributes.pdfName }; + }, + }, + pdfSize: { + parseHTML: (element) => + element + .querySelector("div.infographic") + ?.getAttribute("data-pdf-size"), + renderHTML: (attributes) => { + return { "data-pdf-size": attributes.pdfSize }; + }, + }, + }; + }, + + content: "block+", + + group: "block", + + parseHTML() { + return [ + { + tag: "div.infographic", + getAttrs: (element) => { + const el = element as HTMLElement; + return { + infoName: el.getAttribute("data-infographic") || "", + pdfName: el.getAttribute("data-pdf") || "", + pdfSize: el.getAttribute("data-pdf-size") || "", + }; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + { + class: "infographic", + "data-pdf": HTMLAttributes["data-pdf"], + "data-pdf-size": HTMLAttributes["data-pdf-size"], + "data-infographic": HTMLAttributes["data-infographic"], + }, + [ + "img", + { + src: `${this.options.baseUrl}/${HTMLAttributes["data-infographic"]}`, + height: "auto", + width: "500", + }, + ], + ["div", {}, 0], + ]; + }, + + addCommands() { + return { + setInfographic: + (infoName: string, pdfName: string, pdfSize: string) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { infoName, pdfName, pdfSize }, + content: [ + { + type: "details", + content: [ + { + type: "detailsSummary", + content: [ + { + type: "text", + text: "Afficher le contenu de l'infographie", + }, + ], + }, + { + type: "detailsContent", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Décrire ici le contenu de l'infographie", + }, + ], + }, + ], + }, + ], + }, + ], + }); + }, + + updateInfographicSrc: + (newInfoName: string, newPdfName: string, newPdfSize: string) => + ({ state, chain }) => { + const { selection } = state; + const node = selection.$anchor.node(); + + if (node.type.name !== "infographic") { + return false; + } + return chain() + .updateAttributes("infographic", { + infoName: newInfoName, + pdfName: newPdfName, + pdfSize: newPdfSize, + }) + .run(); + }, + + removeInfographic: + () => + ({ state, dispatch }) => { + const { selection } = state; + const node = selection.$anchor.node(); + + if (node.type.name !== "infographic") { + return false; + } + + if (dispatch) { + const tr = state.tr.delete( + selection.$anchor.before(), + selection.$anchor.after() + ); + dispatch(tr); + } + + return true; + }, + }; + }, +}); diff --git a/targets/frontend/src/components/forms/EditionField/extensions/index.ts b/targets/frontend/src/components/forms/EditionField/extensions/index.ts index 7f9a73ca3..c0df52c3d 100644 --- a/targets/frontend/src/components/forms/EditionField/extensions/index.ts +++ b/targets/frontend/src/components/forms/EditionField/extensions/index.ts @@ -1,2 +1,3 @@ export * from "./Alert"; export * from "./Titles"; +export * from "./Infographic"; diff --git a/targets/frontend/src/components/forms/EditionField/index.tsx b/targets/frontend/src/components/forms/EditionField/index.tsx index a4166be53..bc3de90a6 100644 --- a/targets/frontend/src/components/forms/EditionField/index.tsx +++ b/targets/frontend/src/components/forms/EditionField/index.tsx @@ -5,6 +5,7 @@ import { Controller } from "react-hook-form"; import { CommonFormProps } from "../type"; import { Editor } from "./Editor"; import { styled } from "@mui/system"; +import { buildFilePathUrl } from "../../utils"; type FormEditionProps = CommonFormProps; @@ -21,6 +22,7 @@ export const FormEditionField = (props: FormEditionProps) => { onUpdate={onChange} content={value} disabled={props.disabled} + infographicBaseUrl={buildFilePathUrl()} isError={!!error} /> {error && ( diff --git a/targets/frontend/src/components/utils/fileBaseUrl.ts b/targets/frontend/src/components/utils/fileBaseUrl.ts new file mode 100644 index 000000000..5c75dfc99 --- /dev/null +++ b/targets/frontend/src/components/utils/fileBaseUrl.ts @@ -0,0 +1,6 @@ +export const buildFilePathUrl = () => + `${ + process.env.NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT ?? "http://localhost:9000" + }/${process.env.NEXT_PUBLIC_BUCKET_DRAFT_FOLDER ?? "draft"}/${ + process.env.NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER ?? "default" + }`; diff --git a/targets/frontend/src/components/utils/index.ts b/targets/frontend/src/components/utils/index.ts index 096d75dce..1e703cfc2 100644 --- a/targets/frontend/src/components/utils/index.ts +++ b/targets/frontend/src/components/utils/index.ts @@ -1,2 +1,3 @@ export * from "./Pagination"; export * from "./BreadcrumbLink"; +export * from "./fileBaseUrl"; diff --git a/targets/frontend/src/lib/upload.ts b/targets/frontend/src/lib/upload.ts index a51c08b5a..f4c134cba 100644 --- a/targets/frontend/src/lib/upload.ts +++ b/targets/frontend/src/lib/upload.ts @@ -10,12 +10,14 @@ const mime = require("mime-types"); const region = process.env.BUCKET_REGION ?? "us-east-1"; const endpoint = process.env.BUCKET_ENDPOINT ?? "http://localhost:9000"; const publicEndpoint = - process.env.BUCKET_PUBLIC_ENDPOINT ?? "http://localhost:9000"; + process.env.NEXT_PUBLIC_BUCKET_PUBLIC_ENDPOINT ?? "http://localhost:9000"; const accessKeyId = process.env.BUCKET_ACCESS_KEY ?? "MINIO_ACCESS_KEY"; const secretAccessKey = process.env.BUCKET_SECRET_KEY ?? "MINIO_SECRET_KEY"; const bucketName = process.env.BUCKET_NAME ?? "cdtn"; -const bucketDraftFolder = process.env.BUCKET_DRAFT_FOLDER ?? "draft"; -const bucketDefaultFolder = process.env.BUCKET_DEFAULT_FOLDER ?? "default"; +const bucketDraftFolder = + process.env.NEXT_PUBLIC_BUCKET_DRAFT_FOLDER ?? "draft"; +const bucketDefaultFolder = + process.env.NEXT_PUBLIC_BUCKET_DEFAULT_FOLDER ?? "default"; const client = new S3Client({ region,