diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx new file mode 100644 index 00000000..01eda31e --- /dev/null +++ b/front/components/Canva/Canva.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +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"; +import Youtube from "@tiptap/extension-youtube"; +import { EditorContentWrapper } from "../Editor/Editor"; +import VideoBlock from "../Editor/Extensions/Video/VideoBlock"; + +interface Editor { + content: string; + element: any; + //course: any; +} + +function Canva(props: Editor) { + const isEditable = false; + const editor: any = useEditor({ + editable: isEditable, + extensions: [ + StarterKit, + // Custom Extensions + InfoCallout.configure({ + editable: isEditable, + }), + WarningCallout.configure({ + editable: isEditable, + }), + ImageBlock.configure({ + editable: isEditable, + element: props.element, + }), + VideoBlock.configure({ + editable: true, + element: props.element, + }), + Youtube.configure({ + controls: true, + modestBranding: true, + }), + ], + + content: props.content, + }); + + return ( + + + + ); +} + +export default Canva; diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index d0fb944c..c5ba7ea6 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -10,8 +10,14 @@ import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; import styled from "styled-components"; import { getBackendUrl } from "../../services/config"; -import { GlobeIcon, PaperPlaneIcon, SlashIcon } from "@radix-ui/react-icons"; +import { SlashIcon } from "@radix-ui/react-icons"; 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"; +import Youtube from "@tiptap/extension-youtube"; +import VideoBlock from "./Extensions/Video/VideoBlock"; interface Editor { content: string; @@ -26,23 +32,43 @@ function Editor(props: Editor) { const auth: any = React.useContext(AuthContext); const editor: any = useEditor({ + editable: true, + extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling - history: false, + // history: false, + }), + InfoCallout.configure({ + editable: true, + }), + WarningCallout.configure({ + editable: true, + }), + ImageBlock.configure({ + editable: true, + element: props.element, + }), + VideoBlock.configure({ + editable: true, + element: props.element, + }), + Youtube.configure({ + controls: true, + modestBranding: true, }), // Register the document with Tiptap - Collaboration.configure({ - document: props.ydoc, - }), + // Collaboration.configure({ + // document: props.ydoc, + // }), // Register the collaboration cursor extension - CollaborationCursor.configure({ - provider: props.provider, - user: { - name: auth.userInfo.username, - color: "#f783ac", - }, - }), + // CollaborationCursor.configure({ + // provider: props.provider, + // user: { + // name: auth.userInfo.username, + // color: "#f783ac", + // }, + // }), ], content: props.content, @@ -65,15 +91,13 @@ function Editor(props: Editor) { - + {" "} {props.course.course.name} {props.element.name}{" "} - props.setContent(editor.getJSON())}> - Save - + props.setContent(editor.getJSON())}>Save @@ -90,7 +114,6 @@ function Editor(props: Editor) { ); } + const Page = styled.div` height: 100vh; width: 100%; @@ -113,13 +137,13 @@ const Page = styled.div` min-width: 100vw; padding-top: 30px; - // dots background + // dots background background-image: radial-gradient(#4744446b 1px, transparent 1px), radial-gradient(#4744446b 1px, transparent 1px); background-position: 0 0, 25px 25px; background-size: 50px 50px; background-attachment: fixed; background-repeat: repeat; -` +`; const EditorTop = styled.div` background-color: #ffffffb8; @@ -224,7 +248,7 @@ const EditorInfoThumbnail = styled.img` } `; -const EditorContentWrapper = styled.div` +export const EditorContentWrapper = styled.div` margin: 40px; margin-top: 90px; background-color: white; @@ -236,7 +260,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; @@ -244,6 +269,17 @@ const EditorContentWrapper = styled.div` box-shadow: none !important; } } + + iframe { + border-radius: 6px; + border: none; + min-width: 200px; + width: 100%; + height: 440px; + min-height: 200px; + display: block; + outline: 0px solid transparent; + } `; export default Editor; diff --git a/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts b/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts new file mode 100644 index 00000000..4eed0c32 --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts @@ -0,0 +1,29 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import InfoCalloutComponent from "./InfoCalloutComponent"; + +export default Node.create({ + name: "calloutInfo", + group: "block", + draggable: true, + content: "text*", + + // TODO : multi line support + + parseHTML() { + return [ + { + tag: "callout-info", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["callout-info", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(InfoCalloutComponent); + }, +}); diff --git a/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx b/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx new file mode 100644 index 00000000..e52430e0 --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx @@ -0,0 +1,49 @@ +import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; +import { AlertCircle } from "lucide-react"; +import React from "react"; +import styled from "styled-components"; + +function InfoCalloutComponent(props: any) { + return ( + + + + + + ); +} + +const InfoCalloutWrapper = styled.div` + display: flex; + flex-direction: row; + color: #1f3a8a; + background-color: #dbe9fe; + border: 1px solid #c1d9fb; + border-radius: 16px; + margin: 1rem 0; + align-items: center; + padding-left: 15px; + + svg{ + padding: 3px; + } + + .content { + margin: 5px; + padding: 0.5rem; + border: ${(props) => (props.contentEditable ? "2px dashed #1f3a8a12" : "none")}; + border-radius: 0.5rem; + } +`; + +const DragHandle = styled.div` + position: absolute; + top: 0; + left: 0; + width: 1rem; + height: 100%; + cursor: move; + z-index: 1; +`; + +export default InfoCalloutComponent; diff --git a/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts b/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts new file mode 100644 index 00000000..526e417c --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts @@ -0,0 +1,31 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import WarningCalloutComponent from "./WarningCalloutComponent"; + +export default Node.create({ + name: "calloutWarning", + group: "block", + draggable: true, + content: "text*", + marks: "", + defining: true, + + // TODO : multi line support + + parseHTML() { + return [ + { + tag: "callout-warning", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["callout-info", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(WarningCalloutComponent); + }, +}); diff --git a/front/components/Editor/Extensions/Callout/Warning/WarningCalloutComponent.tsx b/front/components/Editor/Extensions/Callout/Warning/WarningCalloutComponent.tsx new file mode 100644 index 00000000..10f250ec --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Warning/WarningCalloutComponent.tsx @@ -0,0 +1,49 @@ +import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; +import { AlertTriangle } from "lucide-react"; +import React from "react"; +import styled from "styled-components"; + +function WarningCalloutComponent(props: any) { + return ( + + + + + + ); +} + +const CalloutWrapper = styled.div` + display: flex; + flex-direction: row; + background: #fefce8; + color: #713f11; + border: 1px solid #fff103; + border-radius: 16px; + margin: 1rem 0; + align-items: center; + padding-left: 15px; + + svg { + padding: 3px; + } + + .content { + margin: 5px; + padding: 0.5rem; + border: ${(props) => (props.contentEditable ? "2px dashed #713f1117" : "none")}; + border-radius: 0.5rem; + } +`; + +const DragHandle = styled.div` + position: absolute; + top: 0; + left: 0; + width: 1rem; + height: 100%; + cursor: move; + z-index: 1; +`; + +export default WarningCalloutComponent; 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..1f7d727f --- /dev/null +++ b/front/components/Editor/Extensions/Video/VideoBlock.ts @@ -0,0 +1,34 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import VideoBlockComponent from "./VideoBlockComponent"; + +export default Node.create({ + name: "blockVideo", + group: "block", + atom: true, + + addAttributes() { + return { + fileObject: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "block-video", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["block-video", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(VideoBlockComponent); + }, +}); diff --git a/front/components/Editor/Extensions/Video/VideoBlockComponent.tsx b/front/components/Editor/Extensions/Video/VideoBlockComponent.tsx new file mode 100644 index 00000000..671bf023 --- /dev/null +++ b/front/components/Editor/Extensions/Video/VideoBlockComponent.tsx @@ -0,0 +1,86 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { AlertTriangle, Image, Video } from "lucide-react"; +import React from "react"; +import styled from "styled-components"; +import { getBackendUrl } from "../../../../services/config"; +import { uploadNewVideoFile } from "../../../../services/files/video"; + +function VideoBlockComponents(props: any) { + const [video, setVideo] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [fileObject, setfileObject] = React.useState(props.node.attrs.fileObject); + + const handleVideoChange = (event: React.ChangeEvent) => { + setVideo(event.target.files[0]); + }; + + const handleSubmit = async (e: any) => { + e.preventDefault(); + setIsLoading(true); + let object = await uploadNewVideoFile(video, props.extension.options.element.element_id); + setIsLoading(false); + setfileObject(object); + props.updateAttributes({ + fileObject: object, + }); + }; + + return ( + + {!fileObject && ( + +
+
+ +
+ +
+ )} + {fileObject && ( + + + + )} + {isLoading && ( +
+ +
+ )} +
+ ); +} +const BlockVideoWrapper = 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 BlockVideo = styled.div` + display: flex; + flex-direction: column; + video { + width: 100%; + border-radius: 6px; + height: 300px; + // cover + object-fit: cover; + } +`; +export default VideoBlockComponents; diff --git a/front/components/Editor/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index 3e682fbc..87ea1c0d 100644 --- a/front/components/Editor/Toolbar/ToolbarButtons.tsx +++ b/front/components/Editor/Toolbar/ToolbarButtons.tsx @@ -1,11 +1,26 @@ import styled from "styled-components"; -import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons"; +import { AlertCircle, AlertTriangle, ImagePlus, Info, Video, Youtube } from "lucide-react"; export const ToolbarButtons = ({ editor }: any) => { if (!editor) { return null; } + // YouTube extension + + const addYoutubeVideo = () => { + const url = prompt("Enter YouTube URL"); + + if (url) { + editor.commands.setYoutubeVideo({ + src: url, + width: 640, + height: 480, + }); + } + }; + return ( editor.chain().focus().undo().run()}> @@ -23,7 +38,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() + } + > @@ -31,6 +54,42 @@ export const ToolbarButtons = ({ editor }: any) => { + {/* TODO: fix this : toggling only works one-way */} + editor.chain().focus().toggleNode("calloutWarning").run()}> + + + editor.chain().focus().toggleNode("calloutInfo").run()}> + + + + editor + .chain() + .focus() + .insertContent({ + type: "blockImage", + }) + .run() + } + > + + + + editor + .chain() + .focus() + .insertContent({ + type: "blockVideo", + }) + .run() + } + > + + addYoutubeVideo()}> + + ); }; @@ -49,10 +108,13 @@ const ToolBtn = styled.div` width: 25px; height: 25px; padding: 5px; - font-size: 5px; margin-right: 5px; transition: all 0.2s ease-in-out; + svg { + padding: 1px; + } + &.is-active { background: rgba(176, 176, 176, 0.5); diff --git a/front/components/Security/AuthProvider.tsx b/front/components/Security/AuthProvider.tsx index 3eb88197..11905747 100644 --- a/front/components/Security/AuthProvider.tsx +++ b/front/components/Security/AuthProvider.tsx @@ -39,7 +39,7 @@ const AuthProvider = (props: any) => { } } else { setAuth({ access_token, isAuthenticated: false, userInfo, isLoading }); - router.push("/login"); + //router.push("/login"); } } catch (error) { router.push("/"); diff --git a/front/package-lock.json b/front/package-lock.json index 4542cc72..d73a23d4 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -12,11 +12,13 @@ "@radix-ui/react-icons": "^1.1.1", "@tiptap/extension-collaboration": "^2.0.0-beta.199", "@tiptap/extension-collaboration-cursor": "^2.0.0-beta.199", + "@tiptap/extension-youtube": "^2.0.0-beta.207", "@tiptap/html": "^2.0.0-beta.202", "@tiptap/react": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199", "avvvatars-react": "^0.4.2", "framer-motion": "^7.3.6", + "lucide-react": "^0.104.1", "next": "12.3.1", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -1296,6 +1298,18 @@ "@tiptap/core": "^2.0.0-beta.193" } }, + "node_modules/@tiptap/extension-youtube": { + "version": "2.0.0-beta.207", + "resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.0.0-beta.207.tgz", + "integrity": "sha512-fx3adbWZWCysl2Pbw2NNbOVJ+mZT1wJ8YImwXjlM976Z0AWlWY4+O4H2avHzIHQP+t9U/jaV4K6FdcHo6EjJuQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.193" + } + }, "node_modules/@tiptap/html": { "version": "2.0.0-beta.202", "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz", @@ -3532,6 +3546,15 @@ "node": ">=10" } }, + "node_modules/lucide-react": { + "version": "0.104.1", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.104.1.tgz", + "integrity": "sha512-BKvhulnLKmBj+6pqUN5ViYk4a5fabMgc4B0a4ZLUnbRqkDDWH3h7Iet6U4WbesJzjWauQrXUlEvQCe5XpFuRnw==", + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -5961,6 +5984,12 @@ "integrity": "sha512-ntOqEhkBjDHrdzxvpPe4U1JB5GgE9/yyWqWdgzSL9lpSndRTJN1xQLOmyuv0qsLqOgBHn1YITHvaxPb3t8FrFw==", "requires": {} }, + "@tiptap/extension-youtube": { + "version": "2.0.0-beta.207", + "resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.0.0-beta.207.tgz", + "integrity": "sha512-fx3adbWZWCysl2Pbw2NNbOVJ+mZT1wJ8YImwXjlM976Z0AWlWY4+O4H2avHzIHQP+t9U/jaV4K6FdcHo6EjJuQ==", + "requires": {} + }, "@tiptap/html": { "version": "2.0.0-beta.202", "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz", @@ -7577,6 +7606,12 @@ "yallist": "^4.0.0" } }, + "lucide-react": { + "version": "0.104.1", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.104.1.tgz", + "integrity": "sha512-BKvhulnLKmBj+6pqUN5ViYk4a5fabMgc4B0a4ZLUnbRqkDDWH3h7Iet6U4WbesJzjWauQrXUlEvQCe5XpFuRnw==", + "requires": {} + }, "memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", diff --git a/front/package.json b/front/package.json index ec6ffb14..51dd3675 100644 --- a/front/package.json +++ b/front/package.json @@ -13,11 +13,13 @@ "@radix-ui/react-icons": "^1.1.1", "@tiptap/extension-collaboration": "^2.0.0-beta.199", "@tiptap/extension-collaboration-cursor": "^2.0.0-beta.199", + "@tiptap/extension-youtube": "^2.0.0-beta.207", "@tiptap/html": "^2.0.0-beta.202", "@tiptap/react": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199", "avvvatars-react": "^0.4.2", "framer-motion": "^7.3.6", + "lucide-react": "^0.104.1", "next": "12.3.1", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", diff --git a/front/pages/index.tsx b/front/pages/index.tsx index 0fe7549e..cf64a4ae 100644 --- a/front/pages/index.tsx +++ b/front/pages/index.tsx @@ -4,12 +4,10 @@ import styled from "styled-components"; import learnhouseBigIcon from "public/learnhouse_bigicon.png"; import Image from "next/image"; import Link from "next/link"; -import { PreAlphaLabel } from "../components//UI/Layout"; const Home: NextPage = () => { return ( - 🚧 Pre-Alpha { - if (router.isReady && !isLoading) { - console.log(element); - - if (element.type == "dynamic") { - let content = - Object.keys(element.content).length > 0 - ? element.content - : { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Hello world, this is a example Canva ⚡️", - }, - ], - }, - ], - }; - console.log("element", content); - - return generateHTML(content, [Document, StarterKit, Paragraph, Text, Bold]); - } - } - }, [element.content]); - return ( {isLoading ? ( @@ -69,7 +35,7 @@ function ElementPage() {

{element.name}


- {element.type == "dynamic" &&
} + {element.type == "dynamic" && } {/* todo : use apis & streams instead of this */} {element.type == "video" && ( 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/front/services/files/video.ts b/front/services/files/video.ts new file mode 100644 index 00000000..19210d7c --- /dev/null +++ b/front/services/files/video.ts @@ -0,0 +1,38 @@ +import { getAPIUrl } from "../config"; + +export async function uploadNewVideoFile(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/video`, requestOptions) + .then((result) => result.json()) + .catch((error) => console.log("error", error)); +} + +export async function getVideoFile(file_id: string) { + const HeadersConfig = new Headers({ "Content-Type": "application/json" }); + + const requestOptions: any = { + method: "GET", + headers: HeadersConfig, + redirect: "follow", + credentials: "include", + }; + + + return fetch(`${getAPIUrl()}files/video?file_id=${file_id}`, requestOptions) + .then((result) => result.json()) + .catch((error) => console.log("error", error)); +} \ No newline at end of file diff --git a/front/tsconfig.json b/front/tsconfig.json index 99710e85..b8d3d81d 100644 --- a/front/tsconfig.json +++ b/front/tsconfig.json @@ -16,5 +16,12 @@ "incremental": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "paths": { + "@components/*": ["components/*"], + "@public/*": ["public/*"], + "@images/*": ["public/img/*"], + "@services/*": ["services/*"], + "@editor/*": ["components/Editor/*"] + }, "exclude": ["node_modules"] } diff --git a/src/main.py b/src/main.py index c291b269..ffa2f84f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from src.routers import users, auth, houses, orgs, roles +from src.routers import users, auth, houses, orgs, roles, files from src.routers.courses import chapters, collections, courses,elements @@ -12,6 +12,7 @@ global_router.include_router(auth.router, prefix="/auth", tags=["auth"]) global_router.include_router(houses.router, prefix="/houses", tags=["houses"]) global_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) global_router.include_router(roles.router, prefix="/roles", tags=["roles"]) +global_router.include_router(files.router, prefix="/files", tags=["files"]) global_router.include_router(courses.router, prefix="/courses", tags=["courses"]) global_router.include_router(chapters.router, prefix="/chapters", tags=["chapters"]) global_router.include_router(elements.router, prefix="/elements", tags=["elements"]) diff --git a/src/routers/courses/elements.py b/src/routers/courses/elements.py index 32e6ae9d..39226a4d 100644 --- a/src/routers/courses/elements.py +++ b/src/routers/courses/elements.py @@ -47,7 +47,6 @@ async def api_delete_element(element_id: str, current_user: PublicUser = Depends # Video Element - @router.post("/video") async def api_create_video_element(name: str = Form() , coursechapter_id: str = Form(), current_user: PublicUser = Depends(get_current_user), video_file: UploadFile | None = None): """ diff --git a/src/routers/files.py b/src/routers/files.py new file mode 100644 index 00000000..a3169e63 --- /dev/null +++ b/src/routers/files.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, UploadFile, Form +from src.dependencies.auth import get_current_user +from fastapi import HTTPException, status, UploadFile + +from src.services.files.pictures import create_picture_file, get_picture_file +from src.services.files.videos import create_video_file, get_video_file +from src.services.users import PublicUser + +router = APIRouter() + + +@router.post("/picture") +async def api_create_picture_file(file_object: UploadFile, element_id: str = Form(), current_user: PublicUser = Depends(get_current_user)): + """ + Create new picture file + """ + return await create_picture_file(file_object, element_id) + + +@router.post("/video") +async def api_create_video_file(file_object: UploadFile,element_id: str = Form(), current_user: PublicUser = Depends(get_current_user)): + """ + Create new video file + """ + return await create_video_file(file_object, element_id) + + +@router.get("/picture") +async def api_get_picture_file(file_id: str, current_user: PublicUser = Depends(get_current_user)): + """ + Get picture file + """ + return await get_picture_file(file_id, current_user) + + +@router.get("/video") +async def api_get_video_file(file_id: str, current_user: PublicUser = Depends(get_current_user)): + """ + Get video file + """ + return await get_video_file(file_id, current_user) diff --git a/src/services/courses/courses.py b/src/services/courses/courses.py index 5f44ddde..bfab213a 100644 --- a/src/services/courses/courses.py +++ b/src/services/courses/courses.py @@ -1,14 +1,13 @@ import json -import os from typing import List from uuid import uuid4 from pydantic import BaseModel from src.services.courses.elements.elements import ElementInDB -from src.services.uploads import upload_thumbnail -from src.services.users import PublicUser, User -from src.services.database import create_config_collection, check_database, create_database, learnhouseDB +from src.services.courses.thumbnails import upload_thumbnail +from src.services.users import PublicUser +from src.services.database import check_database, learnhouseDB from src.services.security import * -from fastapi import FastAPI, HTTPException, status, Request, Response, BackgroundTasks, UploadFile, File +from fastapi import HTTPException, status, UploadFile from datetime import datetime #### Classes #################################################### @@ -79,7 +78,6 @@ async def get_course_meta(course_id: str, current_user: PublicUser): course = courses.find_one({"course_id": course_id}) elements = learnhouseDB["elements"] - # verify course rights await verify_rights(course_id, current_user, "read") @@ -87,7 +85,6 @@ async def get_course_meta(course_id: str, current_user: PublicUser): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Course does not exist") - coursechapters = coursechapters.find( {"course_id": course_id}).sort("name", 1) diff --git a/src/services/uploads.py b/src/services/courses/elements/uploads/videos.py similarity index 51% rename from src/services/uploads.py rename to src/services/courses/elements/uploads/videos.py index 516f0b1c..2ca74b6f 100644 --- a/src/services/uploads.py +++ b/src/services/courses/elements/uploads/videos.py @@ -1,21 +1,7 @@ import os -async def upload_thumbnail(thumbnail_file, name_in_disk): - contents = thumbnail_file.file.read() - try: - with open(f"content/uploads/img/{name_in_disk}", 'wb') as f: - f.write(contents) - f.close() - - except Exception as e: - print(e) - return {"message": "There was an error uploading the file"} - finally: - thumbnail_file.file.close() - - -async def upload_video(video_file, name_in_disk, element_id): +async def upload_video(video_file, element_id): contents = video_file.file.read() video_format = video_file.filename.split(".")[-1] # create folder diff --git a/src/services/courses/elements/video.py b/src/services/courses/elements/video.py index d66d0851..6b2677fe 100644 --- a/src/services/courses/elements/video.py +++ b/src/services/courses/elements/video.py @@ -1,10 +1,10 @@ from pydantic import BaseModel -from src.services.database import create_config_collection, check_database, create_database, learnhouseDB +from src.services.database import check_database, learnhouseDB from src.services.security import verify_user_rights_with_roles -from src.services.uploads import upload_video -from src.services.users import PublicUser, User -from src.services.courses.elements.elements import ElementInDB, Element -from fastapi import FastAPI, HTTPException, status, Request, Response, BackgroundTasks, UploadFile, File +from src.services.courses.elements.uploads.videos import upload_video +from src.services.users import PublicUser +from src.services.courses.elements.elements import ElementInDB +from fastapi import HTTPException, status, UploadFile from uuid import uuid4 from datetime import datetime @@ -48,7 +48,7 @@ async def create_video_element(name: str, coursechapter_id: str, current_user: print("uploading video") # get videofile format - await upload_video(video_file, video_file.filename, element_id) + await upload_video(video_file, element_id) # todo : choose whether to update the chapter or not # update chapter diff --git a/src/services/courses/thumbnails.py b/src/services/courses/thumbnails.py new file mode 100644 index 00000000..1306038d --- /dev/null +++ b/src/services/courses/thumbnails.py @@ -0,0 +1,15 @@ +import os + + +async def upload_thumbnail(thumbnail_file, name_in_disk): + contents = thumbnail_file.file.read() + try: + with open(f"content/uploads/img/{name_in_disk}", 'wb') as f: + f.write(contents) + f.close() + + except Exception as e: + print(e) + return {"message": "There was an error uploading the file"} + finally: + thumbnail_file.file.close() \ No newline at end of file diff --git a/src/services/files/pictures.py b/src/services/files/pictures.py new file mode 100644 index 00000000..3a0fdba0 --- /dev/null +++ b/src/services/files/pictures.py @@ -0,0 +1,116 @@ +from uuid import uuid4 +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 + + +class PhotoFile(BaseModel): + file_id: str + file_format: str + file_name: str + file_size: int + file_type: str + element_id: str + + +async def create_picture_file(picture_file: UploadFile, element_id: str): + await check_database() + photos = learnhouseDB["files"] + + # generate file_id + file_id = str(f"file_{uuid4()}") + + # get file format + file_format = picture_file.filename.split(".")[-1] + + # validate file format + if file_format not in ["jpg", "jpeg", "png", "gif"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Picture file format not supported") + + # create file + file = await picture_file.read() + + + # get file size + file_size = len(file) + + # get file type + file_type = picture_file.content_type + + # get file name + file_name = picture_file.filename + + # create file object + uploadable_file = PhotoFile( + file_id=file_id, + file_format=file_format, + file_name=file_name, + file_size=file_size, + file_type=file_type, + 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) + f.close() + + # insert file object into database + photo_file_in_db = photos.insert_one(uploadable_file.dict()) + + if not photo_file_in_db: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Photo file could not be created") + + return uploadable_file + + +async def get_picture_object(file_id: str): + await check_database() + photos = learnhouseDB["files"] + + photo_file = photos.find_one({"file_id": file_id}) + + if photo_file: + photo_file = PhotoFile(**photo_file) + return photo_file + + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Photo file does not exist") + + +async def get_picture_file(file_id: str, current_user: PublicUser): + await check_database() + photos = learnhouseDB["files"] + + photo_file = photos.find_one({"file_id": file_id}) + + # check media type + if photo_file.format not in ["jpg", "jpeg", "png", "gif"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Photo file format not supported") + + # TODO : check if user has access to file + + if photo_file: + # stream file + photo_file = PhotoFile(**photo_file) + file_format = photo_file.file_format + element_id = photo_file.element_id + file = open( + f"content/uploads/files/pictures/{element_id}/{file_id}.{file_format}", 'rb') + return StreamingResponse(file, media_type=photo_file.file_type) + + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Photo file does not exist") diff --git a/src/services/files/videos.py b/src/services/files/videos.py new file mode 100644 index 00000000..47b5a563 --- /dev/null +++ b/src/services/files/videos.py @@ -0,0 +1,118 @@ +from uuid import uuid4 +from pydantic import BaseModel +import os +from src.services.database import check_database, learnhouseDB, learnhouseDB +from fastapi import HTTPException, status, UploadFile +from fastapi.responses import StreamingResponse + +from src.services.users import PublicUser + + +class VideoFile(BaseModel): + file_id: str + file_format: str + file_name: str + file_size: int + file_type: str + element_id: str + + +async def create_video_file(video_file: UploadFile, element_id: str): + await check_database() + files = learnhouseDB["files"] + + # generate file_id + file_id = str(f"file_{uuid4()}") + + # get file format + file_format = video_file.filename.split(".")[-1] + + # validate file format + if file_format not in ["mp4", "webm", "ogg"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Video file format not supported") + + # create file + file = await video_file.read() + + # get file size + file_size = len(file) + + # get file type + file_type = video_file.content_type + + # get file name + file_name = video_file.filename + + # create file object + uploadable_file = VideoFile( + file_id=file_id, + file_format=file_format, + file_name=file_name, + file_size=file_size, + file_type=file_type, + element_id=element_id + ) + + # create folder for element + if not os.path.exists(f"content/uploads/files/videos/{element_id}"): + os.mkdir(f"content/uploads/files/videos/{element_id}") + + # upload file to server + with open(f"content/uploads/files/videos/{element_id}/{file_id}.{file_format}", 'wb') as f: + f.write(file) + f.close() + + # insert file object into database + video_file_in_db = files.insert_one(uploadable_file.dict()) + + if not video_file_in_db: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Video file could not be created") + + return uploadable_file + + +async def get_video_object(file_id: str, current_user: PublicUser): + await check_database() + photos = learnhouseDB["files"] + + video_file = photos.find_one({"file_id": file_id}) + + if video_file: + video_file = VideoFile(**video_file) + return video_file + + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Photo file does not exist") + + +async def get_video_file(file_id: str, current_user: PublicUser): + await check_database() + photos = learnhouseDB["files"] + + video_file = photos.find_one({"file_id": file_id}) + + # check media type + if video_file.format not in ["mp4", "webm", "ogg"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Video file format not supported") + + # TODO : check if user has access to file + + if video_file: + # stream file + video_file = VideoFile(**video_file) + file_format = video_file.file_format + element_id = video_file.element_id + + def iterfile(): # + # + with open(f"content/uploads/files/videos/{element_id}/{file_id}.{file_format}", mode="rb") as file_like: + yield from file_like + return StreamingResponse(iterfile(), media_type=video_file.file_type) + + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Video file does not exist")