diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx index a1521101..49926086 100644 --- a/front/components/Canva/Canva.tsx +++ b/front/components/Canva/Canva.tsx @@ -4,6 +4,7 @@ import StarterKit from "@tiptap/starter-kit"; // Custom Extensions import InfoCallout from "../Editor/Extensions/Callout/Info/InfoCallout"; import WarningCallout from "../Editor/Extensions/Callout/Warning/WarningCallout"; +import ImageBlock from "../Editor/Extensions/Image/ImageBlock"; interface Editor { content: string; @@ -24,6 +25,10 @@ function Canva(props: Editor) { WarningCallout.configure({ editable: isEditable, }), + ImageBlock.configure({ + editable: isEditable, + element: props.element, + }), ], content: props.content, diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index d197c041..5e135265 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -15,6 +15,7 @@ import Avvvatars from "avvvatars-react"; // extensions import InfoCallout from "./Extensions/Callout/Info/InfoCallout"; import WarningCallout from "./Extensions/Callout/Warning/WarningCallout"; +import ImageBlock from "./Extensions/Image/ImageBlock"; interface Editor { content: string; @@ -30,6 +31,7 @@ function Editor(props: Editor) { const editor: any = useEditor({ editable: true, + extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling @@ -41,6 +43,10 @@ function Editor(props: Editor) { WarningCallout.configure({ editable: true, }), + ImageBlock.configure({ + editable: true, + element: props.element + }), // Register the document with Tiptap // Collaboration.configure({ // document: props.ydoc, @@ -244,7 +250,8 @@ const EditorContentWrapper = styled.div` .ProseMirror { padding-left: 20px; padding-right: 20px; - padding-bottom: 6px; + padding-bottom: 20px; + padding-top: 1px; &:focus { outline: none !important; diff --git a/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts b/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts index a12aaf45..4eed0c32 100644 --- a/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts +++ b/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts @@ -7,7 +7,9 @@ export default Node.create({ name: "calloutInfo", group: "block", draggable: true, - content: "inline*", + content: "text*", + + // TODO : multi line support parseHTML() { return [ diff --git a/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts b/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts index c5c972f2..526e417c 100644 --- a/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts +++ b/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts @@ -7,7 +7,11 @@ export default Node.create({ name: "calloutWarning", group: "block", draggable: true, - content: "inline*", + content: "text*", + marks: "", + defining: true, + + // TODO : multi line support parseHTML() { return [ diff --git a/front/components/Editor/Extensions/Image/ImageBlock.ts b/front/components/Editor/Extensions/Image/ImageBlock.ts new file mode 100644 index 00000000..143c9076 --- /dev/null +++ b/front/components/Editor/Extensions/Image/ImageBlock.ts @@ -0,0 +1,35 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import ImageBlockComponent from "./ImageBlockComponent"; + +export default Node.create({ + name: "blockImage", + group: "block", + + atom: true, + + addAttributes() { + return { + fileObject: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "block-image", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["block-image", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageBlockComponent); + }, +}); diff --git a/front/components/Editor/Extensions/Image/ImageBlockComponent.tsx b/front/components/Editor/Extensions/Image/ImageBlockComponent.tsx new file mode 100644 index 00000000..af8ed151 --- /dev/null +++ b/front/components/Editor/Extensions/Image/ImageBlockComponent.tsx @@ -0,0 +1,89 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import React from "react"; +import styled from "styled-components"; +import { AlertCircle, AlertTriangle, Image, ImagePlus, Info } from "lucide-react"; +import { getImageFile, uploadNewImageFile } from "../../../../services/files/images"; +import { getBackendUrl } from "../../../../services/config"; + +function ImageBlockComponent(props: any) { + const [image, setImage] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [fileObject, setfileObject] = React.useState(props.node.attrs.fileObject); + + const handleImageChange = (event: React.ChangeEvent) => { + setImage(event.target.files[0]); + }; + + const handleSubmit = async (e: any) => { + e.preventDefault(); + setIsLoading(true); + let object = await uploadNewImageFile(image, props.extension.options.element.element_id); + setIsLoading(false); + setfileObject(object); + props.updateAttributes({ + fileObject: object, + }); + }; + + return ( + + {!fileObject && ( + +
+ +
+
+ +
+ +
+ )} + {fileObject && ( + + + + )} + {isLoading && ( +
+ +
+ )} +
+ ); +} + +export default ImageBlockComponent; + +const BlockImageWrapper = styled.div` + display: flex; + flex-direction: column; + background: #f9f9f9; + border-radius: 3px; + padding: 30px; + min-height: 74px; + border: ${(props) => (props.contentEditable ? "2px dashed #713f1117" : "none")}; + + // center + align-items: center; + justify-content: center; + text-align: center; + font-size: 14px; +`; + +const BlockImage = styled.div` + display: flex; + flex-direction: column; + img { + width: 100%; + border-radius: 6px; + height: 300px; + // cover + object-fit: cover; + } +`; +const ImageNotFound = styled.div``; diff --git a/front/components/Editor/Extensions/Video/VideoBlock.ts b/front/components/Editor/Extensions/Video/VideoBlock.ts new file mode 100644 index 00000000..bb81be3f --- /dev/null +++ b/front/components/Editor/Extensions/Video/VideoBlock.ts @@ -0,0 +1,29 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import VideoBlockComponent from "./VideoBlockComponent"; + +export default Node.create({ + name: "calloutWarning", + group: "block", + draggable: true, + content: "inline*", + + // TODO : multi line support + + parseHTML() { + return [ + { + tag: "callout-warning", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["callout-info", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(VideoBlockComponent); + }, +}); diff --git a/front/components/Editor/Extensions/Video/VideoBlockComponents.tsx b/front/components/Editor/Extensions/Video/VideoBlockComponents.tsx new file mode 100644 index 00000000..e69de29b diff --git a/front/components/Editor/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index 28b1b99a..d8ba94bd 100644 --- a/front/components/Editor/Toolbar/ToolbarButtons.tsx +++ b/front/components/Editor/Toolbar/ToolbarButtons.tsx @@ -1,6 +1,6 @@ import styled from "styled-components"; import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons"; -import { AlertCircle, AlertTriangle, Info } from "lucide-react"; +import { AlertCircle, AlertTriangle, ImagePlus, Info } from "lucide-react"; export const ToolbarButtons = ({ editor }: any) => { if (!editor) { @@ -24,7 +24,15 @@ export const ToolbarButtons = ({ editor }: any) => { editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}> - editor.chain().focus().toggleHeading({ level: parseInt(e.target.value) }).run() }> + + editor + .chain() + .focus() + .toggleHeading({ level: parseInt(e.target.value) }) + .run() + } + > @@ -33,12 +41,25 @@ export const ToolbarButtons = ({ editor }: any) => { {/* TODO: fix this : toggling only works one-way */} - editor.chain().focus().toggleNode('calloutWarning').run()} > + editor.chain().focus().toggleNode("calloutWarning").run()}> - editor.chain().focus().toggleNode('calloutInfo').run()} > + editor.chain().focus().toggleNode("calloutInfo").run()}> + + editor + .chain() + .focus() + .insertContent({ + type: "blockImage", + }) + .run() + } + > + + ); }; @@ -60,7 +81,7 @@ const ToolBtn = styled.div` margin-right: 5px; transition: all 0.2s ease-in-out; - svg{ + svg { padding: 1px; } diff --git a/front/services/files/images.ts b/front/services/files/images.ts new file mode 100644 index 00000000..9700755d --- /dev/null +++ b/front/services/files/images.ts @@ -0,0 +1,38 @@ +import { getAPIUrl } from "../config"; + +export async function uploadNewImageFile(file: any, element_id: string) { + const HeadersConfig = new Headers(); + + // Send file thumbnail as form data + const formData = new FormData(); + formData.append("file_object", file); + formData.append("element_id", element_id); + + const requestOptions: any = { + method: "POST", + headers: HeadersConfig, + redirect: "follow", + credentials: "include", + body: formData, + }; + + return fetch(`${getAPIUrl()}files/picture`, requestOptions) + .then((result) => result.json()) + .catch((error) => console.log("error", error)); +} + +export async function getImageFile(file_id: string) { + const HeadersConfig = new Headers({ "Content-Type": "application/json" }); + + const requestOptions: any = { + method: "GET", + headers: HeadersConfig, + redirect: "follow", + credentials: "include", + }; + + // todo : add course id to url + return fetch(`${getAPIUrl()}files/picture?file_id=${file_id}`, requestOptions) + .then((result) => result.json()) + .catch((error) => console.log("error", error)); +} \ No newline at end of file diff --git a/src/services/files/pictures.py b/src/services/files/pictures.py index ad5b4701..3a0fdba0 100644 --- a/src/services/files/pictures.py +++ b/src/services/files/pictures.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from src.services.database import check_database, learnhouseDB, learnhouseDB from fastapi import HTTPException, status, UploadFile from fastapi.responses import StreamingResponse +import os from src.services.users import PublicUser @@ -54,6 +55,10 @@ async def create_picture_file(picture_file: UploadFile, element_id: str): element_id=element_id ) + # create folder for element + if not os.path.exists(f"content/uploads/files/pictures/{element_id}"): + os.mkdir(f"content/uploads/files/pictures/{element_id}") + # upload file to server with open(f"content/uploads/files/pictures/{element_id}/{file_id}.{file_format}", 'wb') as f: f.write(file)