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")