From fe8fdd1769d5abf9cde3b35d1d543eedbeda966d Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 8 Dec 2022 10:12:46 +0100 Subject: [PATCH 1/8] feat: add info callout custom extension --- front/components/Canva/Canva.tsx | 32 +++++++++++++ front/components/Editor/Editor.tsx | 42 +++++++++-------- .../Extensions/Callout/Info/InfoCallout.ts | 27 +++++++++++ .../Callout/Info/InfoCalloutComponent.tsx | 45 +++++++++++++++++++ .../Editor/Toolbar/ToolbarButtons.tsx | 12 ++++- front/package-lock.json | 16 +++++++ front/package.json | 1 + front/pages/index.tsx | 2 - .../[courseid]/element/[elementid]/index.tsx | 38 +--------------- front/tsconfig.json | 7 +++ 10 files changed, 163 insertions(+), 59 deletions(-) create mode 100644 front/components/Canva/Canva.tsx create mode 100644 front/components/Editor/Extensions/Callout/Info/InfoCallout.ts create mode 100644 front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx new file mode 100644 index 00000000..81991667 --- /dev/null +++ b/front/components/Canva/Canva.tsx @@ -0,0 +1,32 @@ +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"; + +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, + }), + ], + + content: props.content, + }); + + return ; +} + +export default Canva; diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index d0fb944c..6f098fd9 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -10,8 +10,10 @@ 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"; interface Editor { content: string; @@ -26,23 +28,27 @@ 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, }), // 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 +71,13 @@ function Editor(props: Editor) { - + {" "} {props.course.course.name} {props.element.name}{" "} - props.setContent(editor.getJSON())}> - Save - + props.setContent(editor.getJSON())}>Save @@ -90,7 +94,6 @@ function Editor(props: Editor) { ); } + const Page = styled.div` height: 100vh; width: 100%; @@ -113,13 +117,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; 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..a12aaf45 --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Info/InfoCallout.ts @@ -0,0 +1,27 @@ +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: "inline*", + + 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..7ef60f05 --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx @@ -0,0 +1,45 @@ +import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; +import React from "react"; +import styled from "styled-components"; + +function InfoCalloutComponent(props: any) { + return ( + + +
⚠️
+
+
+ ); +} + +const InfoCalloutWrapper = 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; + + + .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 InfoCalloutComponent; diff --git a/front/components/Editor/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index 3e682fbc..995a4a03 100644 --- a/front/components/Editor/Toolbar/ToolbarButtons.tsx +++ b/front/components/Editor/Toolbar/ToolbarButtons.tsx @@ -1,5 +1,6 @@ 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 { AlertTriangle, Info } from "lucide-react"; export const ToolbarButtons = ({ editor }: any) => { if (!editor) { @@ -31,6 +32,10 @@ export const ToolbarButtons = ({ editor }: any) => { + {/* TODO: fix this : toggling only works one-way */} + editor.chain().focus().toggleNode('calloutInfo').run()} > + + ); }; @@ -49,10 +54,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/package-lock.json b/front/package-lock.json index 4542cc72..a342a827 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -17,6 +17,7 @@ "@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", @@ -3532,6 +3533,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", @@ -7577,6 +7587,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..1f7280a8 100644 --- a/front/package.json +++ b/front/package.json @@ -18,6 +18,7 @@ "@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/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"] } From 88644ad9025a39943cff8ab8671811713d8e7db4 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 8 Dec 2022 10:31:43 +0100 Subject: [PATCH 2/8] feat: add warning custom extension --- front/components/Canva/Canva.tsx | 5 +- front/components/Editor/Editor.tsx | 6 ++- .../Callout/Info/InfoCalloutComponent.tsx | 14 ++++-- .../Callout/Warning/WarningCallout.ts | 27 ++++++++++ .../Warning/WarningCalloutComponent.tsx | 49 +++++++++++++++++++ .../Editor/Toolbar/ToolbarButtons.tsx | 7 ++- 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts create mode 100644 front/components/Editor/Extensions/Callout/Warning/WarningCalloutComponent.tsx diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx index 81991667..a1521101 100644 --- a/front/components/Canva/Canva.tsx +++ b/front/components/Canva/Canva.tsx @@ -3,6 +3,7 @@ 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"; interface Editor { content: string; @@ -16,11 +17,13 @@ function Canva(props: Editor) { editable: isEditable, extensions: [ StarterKit, - // Custom Extensions InfoCallout.configure({ editable: isEditable, }), + WarningCallout.configure({ + editable: isEditable, + }), ], content: props.content, diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index 6f098fd9..d197c041 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -14,6 +14,7 @@ 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"; interface Editor { content: string; @@ -32,11 +33,14 @@ function Editor(props: Editor) { extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling - // history: false, + // history: false, }), InfoCallout.configure({ editable: true, }), + WarningCallout.configure({ + editable: true, + }), // Register the document with Tiptap // Collaboration.configure({ // document: props.ydoc, diff --git a/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx b/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx index 7ef60f05..e52430e0 100644 --- a/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx +++ b/front/components/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx @@ -1,4 +1,5 @@ import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; +import { AlertCircle } from "lucide-react"; import React from "react"; import styled from "styled-components"; @@ -6,7 +7,7 @@ function InfoCalloutComponent(props: any) { return ( -
⚠️
+
); @@ -15,19 +16,22 @@ function InfoCalloutComponent(props: any) { const InfoCalloutWrapper = styled.div` display: flex; flex-direction: row; - background: #fefce8; - color: #713f11; - border: 1px solid #fff103; + 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 #713f1117" : "none")}; + border: ${(props) => (props.contentEditable ? "2px dashed #1f3a8a12" : "none")}; border-radius: 0.5rem; } `; 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..c5c972f2 --- /dev/null +++ b/front/components/Editor/Extensions/Callout/Warning/WarningCallout.ts @@ -0,0 +1,27 @@ +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: "inline*", + + 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/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index 995a4a03..28b1b99a 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 { AlertTriangle, Info } from "lucide-react"; +import { AlertCircle, AlertTriangle, Info } from "lucide-react"; export const ToolbarButtons = ({ editor }: any) => { if (!editor) { @@ -33,9 +33,12 @@ export const ToolbarButtons = ({ editor }: any) => { {/* TODO: fix this : toggling only works one-way */} - editor.chain().focus().toggleNode('calloutInfo').run()} > + editor.chain().focus().toggleNode('calloutWarning').run()} > + editor.chain().focus().toggleNode('calloutInfo').run()} > + + ); }; From 805df9b52014cc53d8571ad9e4a90c2ffe8a56f5 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 11 Dec 2022 17:09:07 +0100 Subject: [PATCH 3/8] fix: reformat upload files --- src/services/courses/courses.py | 11 ++++------- .../elements/uploads/videos.py} | 16 +--------------- src/services/courses/elements/video.py | 12 ++++++------ src/services/courses/files/photos.py | 4 ++++ src/services/courses/files/videos.py | 0 src/services/courses/thumbnails.py | 15 +++++++++++++++ 6 files changed, 30 insertions(+), 28 deletions(-) rename src/services/{uploads.py => courses/elements/uploads/videos.py} (51%) create mode 100644 src/services/courses/files/photos.py create mode 100644 src/services/courses/files/videos.py create mode 100644 src/services/courses/thumbnails.py 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..b997da7d 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 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/files/photos.py b/src/services/courses/files/photos.py new file mode 100644 index 00000000..d440e5ea --- /dev/null +++ b/src/services/courses/files/photos.py @@ -0,0 +1,4 @@ +from uuid import uuid4 + + + \ No newline at end of file diff --git a/src/services/courses/files/videos.py b/src/services/courses/files/videos.py new file mode 100644 index 00000000..e69de29b 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 From 4e539865c96a1d44c3252b3bcd5f1ede25e22bfd Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 12 Dec 2022 11:08:54 +0100 Subject: [PATCH 4/8] feat: init files upload/get backend code --- src/main.py | 3 +- src/routers/courses/elements.py | 1 - src/routers/files.py | 40 +++++++++ src/services/courses/elements/video.py | 2 +- src/services/courses/files/photos.py | 4 - src/services/courses/files/videos.py | 0 src/services/files/pictures.py | 111 +++++++++++++++++++++++ src/services/files/videos.py | 117 +++++++++++++++++++++++++ 8 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 src/routers/files.py delete mode 100644 src/services/courses/files/photos.py delete mode 100644 src/services/courses/files/videos.py create mode 100644 src/services/files/pictures.py create mode 100644 src/services/files/videos.py 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..2cecd884 --- /dev/null +++ b/src/routers/files.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends +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, current_user: PublicUser = Depends(get_current_user)): + """ + Create new picture file + """ + return await create_picture_file(file_object, "ed_123") + + +@router.post("/video") +async def api_create_video_file(file_object: UploadFile, current_user: PublicUser = Depends(get_current_user)): + """ + Create new video file + """ + return await create_video_file(file_object, "ed_123") + +@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/elements/video.py b/src/services/courses/elements/video.py index b997da7d..6b2677fe 100644 --- a/src/services/courses/elements/video.py +++ b/src/services/courses/elements/video.py @@ -1,7 +1,7 @@ from pydantic import BaseModel from src.services.database import check_database, learnhouseDB from src.services.security import verify_user_rights_with_roles -from src.services.courses.elements.uploads import upload_video +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 diff --git a/src/services/courses/files/photos.py b/src/services/courses/files/photos.py deleted file mode 100644 index d440e5ea..00000000 --- a/src/services/courses/files/photos.py +++ /dev/null @@ -1,4 +0,0 @@ -from uuid import uuid4 - - - \ No newline at end of file diff --git a/src/services/courses/files/videos.py b/src/services/courses/files/videos.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/services/files/pictures.py b/src/services/files/pictures.py new file mode 100644 index 00000000..ad5b4701 --- /dev/null +++ b/src/services/files/pictures.py @@ -0,0 +1,111 @@ +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 + +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 + ) + + # 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..6acec817 --- /dev/null +++ b/src/services/files/videos.py @@ -0,0 +1,117 @@ +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 + 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") From 643d4ae2e734134b1f7e51cb52f60a0a3b0badf6 Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 12 Dec 2022 11:29:47 +0100 Subject: [PATCH 5/8] feat: add element_id to file upload params --- src/routers/files.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/routers/files.py b/src/routers/files.py index 2cecd884..a3169e63 100644 --- a/src/routers/files.py +++ b/src/routers/files.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, UploadFile, Form from src.dependencies.auth import get_current_user from fastapi import HTTPException, status, UploadFile @@ -10,31 +10,32 @@ router = APIRouter() @router.post("/picture") -async def api_create_picture_file(file_object: UploadFile, current_user: PublicUser = Depends(get_current_user)): +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, "ed_123") + return await create_picture_file(file_object, element_id) @router.post("/video") -async def api_create_video_file(file_object: UploadFile, current_user: PublicUser = Depends(get_current_user)): +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, "ed_123") + 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)): +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)): +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) - From 5eb9101084fe1802c10b69cae8f33fb638ef1503 Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 13 Dec 2022 18:18:51 +0100 Subject: [PATCH 6/8] feat: add pictures extension to editor and canva --- front/components/Canva/Canva.tsx | 5 ++ front/components/Editor/Editor.tsx | 9 +- .../Extensions/Callout/Info/InfoCallout.ts | 4 +- .../Callout/Warning/WarningCallout.ts | 6 +- .../Editor/Extensions/Image/ImageBlock.ts | 35 ++++++++ .../Extensions/Image/ImageBlockComponent.tsx | 89 +++++++++++++++++++ .../Editor/Extensions/Video/VideoBlock.ts | 29 ++++++ .../Extensions/Video/VideoBlockComponents.tsx | 0 .../Editor/Toolbar/ToolbarButtons.tsx | 31 +++++-- front/services/files/images.ts | 38 ++++++++ src/services/files/pictures.py | 5 ++ 11 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 front/components/Editor/Extensions/Image/ImageBlock.ts create mode 100644 front/components/Editor/Extensions/Image/ImageBlockComponent.tsx create mode 100644 front/components/Editor/Extensions/Video/VideoBlock.ts create mode 100644 front/components/Editor/Extensions/Video/VideoBlockComponents.tsx create mode 100644 front/services/files/images.ts 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) From 8d0efdb93efd6d3054e0cb2f152d8c3b58506769 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 14 Dec 2022 00:08:46 +0100 Subject: [PATCH 7/8] feat: init youtube editor extension --- front/components/Canva/Canva.tsx | 12 +++++++++- front/components/Editor/Editor.tsx | 22 ++++++++++++++++--- .../Editor/Toolbar/ToolbarButtons.tsx | 19 +++++++++++++++- front/package-lock.json | 19 ++++++++++++++++ front/package.json | 1 + 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx index 49926086..97a466c1 100644 --- a/front/components/Canva/Canva.tsx +++ b/front/components/Canva/Canva.tsx @@ -5,6 +5,8 @@ import StarterKit from "@tiptap/starter-kit"; 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"; interface Editor { content: string; @@ -29,12 +31,20 @@ function Canva(props: Editor) { editable: isEditable, element: props.element, }), + Youtube.configure({ + controls: true, + modestBranding: true, + }), ], content: props.content, }); - return ; + return ( + + + + ); } export default Canva; diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index 5e135265..54a12543 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -16,6 +16,7 @@ import Avvvatars from "avvvatars-react"; 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"; interface Editor { content: string; @@ -31,7 +32,7 @@ function Editor(props: Editor) { const editor: any = useEditor({ editable: true, - + extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling @@ -45,7 +46,11 @@ function Editor(props: Editor) { }), ImageBlock.configure({ editable: true, - element: props.element + element: props.element, + }), + Youtube.configure({ + controls: true, + modestBranding: true, }), // Register the document with Tiptap // Collaboration.configure({ @@ -238,7 +243,7 @@ const EditorInfoThumbnail = styled.img` } `; -const EditorContentWrapper = styled.div` +export const EditorContentWrapper = styled.div` margin: 40px; margin-top: 90px; background-color: white; @@ -259,6 +264,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/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index d8ba94bd..e793779a 100644 --- a/front/components/Editor/Toolbar/ToolbarButtons.tsx +++ b/front/components/Editor/Toolbar/ToolbarButtons.tsx @@ -1,12 +1,26 @@ import styled from "styled-components"; import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons"; -import { AlertCircle, AlertTriangle, ImagePlus, Info } from "lucide-react"; +import { AlertCircle, AlertTriangle, ImagePlus, Info, 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()}> @@ -60,6 +74,9 @@ export const ToolbarButtons = ({ editor }: any) => { > + addYoutubeVideo()}> + + ); }; diff --git a/front/package-lock.json b/front/package-lock.json index a342a827..d73a23d4 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -12,6 +12,7 @@ "@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", @@ -1297,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", @@ -5971,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", diff --git a/front/package.json b/front/package.json index 1f7280a8..51dd3675 100644 --- a/front/package.json +++ b/front/package.json @@ -13,6 +13,7 @@ "@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", From 9f916449c54b6aa2bc1cad0d46fb3b9f93c130f4 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 14 Dec 2022 17:49:21 +0100 Subject: [PATCH 8/8] feat: init and add video extension to editor/canva --- front/components/Canva/Canva.tsx | 5 ++ front/components/Editor/Editor.tsx | 5 ++ .../Editor/Extensions/Video/VideoBlock.ts | 19 ++-- .../Extensions/Video/VideoBlockComponent.tsx | 86 +++++++++++++++++++ .../Extensions/Video/VideoBlockComponents.tsx | 0 .../Editor/Toolbar/ToolbarButtons.tsx | 15 +++- front/components/Security/AuthProvider.tsx | 2 +- front/services/files/video.ts | 38 ++++++++ src/services/files/videos.py | 3 +- 9 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 front/components/Editor/Extensions/Video/VideoBlockComponent.tsx delete mode 100644 front/components/Editor/Extensions/Video/VideoBlockComponents.tsx create mode 100644 front/services/files/video.ts diff --git a/front/components/Canva/Canva.tsx b/front/components/Canva/Canva.tsx index 97a466c1..01eda31e 100644 --- a/front/components/Canva/Canva.tsx +++ b/front/components/Canva/Canva.tsx @@ -7,6 +7,7 @@ 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; @@ -31,6 +32,10 @@ function Canva(props: Editor) { editable: isEditable, element: props.element, }), + VideoBlock.configure({ + editable: true, + element: props.element, + }), Youtube.configure({ controls: true, modestBranding: true, diff --git a/front/components/Editor/Editor.tsx b/front/components/Editor/Editor.tsx index 54a12543..c5ba7ea6 100644 --- a/front/components/Editor/Editor.tsx +++ b/front/components/Editor/Editor.tsx @@ -17,6 +17,7 @@ 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; @@ -48,6 +49,10 @@ function Editor(props: Editor) { editable: true, element: props.element, }), + VideoBlock.configure({ + editable: true, + element: props.element, + }), Youtube.configure({ controls: true, modestBranding: true, diff --git a/front/components/Editor/Extensions/Video/VideoBlock.ts b/front/components/Editor/Extensions/Video/VideoBlock.ts index bb81be3f..1f7d727f 100644 --- a/front/components/Editor/Extensions/Video/VideoBlock.ts +++ b/front/components/Editor/Extensions/Video/VideoBlock.ts @@ -4,23 +4,28 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; import VideoBlockComponent from "./VideoBlockComponent"; export default Node.create({ - name: "calloutWarning", + name: "blockVideo", group: "block", - draggable: true, - content: "inline*", - - // TODO : multi line support + atom: true, + + addAttributes() { + return { + fileObject: { + default: null, + }, + }; + }, parseHTML() { return [ { - tag: "callout-warning", + tag: "block-video", }, ]; }, renderHTML({ HTMLAttributes }) { - return ["callout-info", mergeAttributes(HTMLAttributes), 0]; + return ["block-video", mergeAttributes(HTMLAttributes), 0]; }, addNodeView() { 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/Extensions/Video/VideoBlockComponents.tsx b/front/components/Editor/Extensions/Video/VideoBlockComponents.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/front/components/Editor/Toolbar/ToolbarButtons.tsx b/front/components/Editor/Toolbar/ToolbarButtons.tsx index e793779a..87ea1c0d 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, ImagePlus, Info, Youtube } from "lucide-react"; +import { AlertCircle, AlertTriangle, ImagePlus, Info, Video, Youtube } from "lucide-react"; export const ToolbarButtons = ({ editor }: any) => { if (!editor) { @@ -74,6 +74,19 @@ export const ToolbarButtons = ({ editor }: any) => { >
+ + editor + .chain() + .focus() + .insertContent({ + type: "blockVideo", + }) + .run() + } + > + addYoutubeVideo()}> 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/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/src/services/files/videos.py b/src/services/files/videos.py index 6acec817..47b5a563 100644 --- a/src/services/files/videos.py +++ b/src/services/files/videos.py @@ -55,7 +55,8 @@ async def create_video_file(video_file: UploadFile, element_id: str): ) # create folder for element - os.mkdir(f"content/uploads/files/videos/{element_id}") + 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: