Merge pull request #15 from learnhouse/feat/custom-editor-inputs

Feat/custom editor inputs
This commit is contained in:
Badr B 2022-12-14 17:50:49 +01:00 committed by GitHub
commit eebaee42a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1005 additions and 93 deletions

View file

@ -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 (
<EditorContentWrapper>
<EditorContent editor={editor} />
</EditorContentWrapper>
);
}
export default Canva;

View file

@ -10,8 +10,14 @@ import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image"; import Image from "next/image";
import styled from "styled-components"; import styled from "styled-components";
import { getBackendUrl } from "../../services/config"; 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"; 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 { interface Editor {
content: string; content: string;
@ -26,23 +32,43 @@ function Editor(props: Editor) {
const auth: any = React.useContext(AuthContext); const auth: any = React.useContext(AuthContext);
const editor: any = useEditor({ const editor: any = useEditor({
editable: true,
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
// The Collaboration extension comes with its own history handling // 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 // Register the document with Tiptap
Collaboration.configure({ // Collaboration.configure({
document: props.ydoc, // document: props.ydoc,
}), // }),
// Register the collaboration cursor extension // Register the collaboration cursor extension
CollaborationCursor.configure({ // CollaborationCursor.configure({
provider: props.provider, // provider: props.provider,
user: { // user: {
name: auth.userInfo.username, // name: auth.userInfo.username,
color: "#f783ac", // color: "#f783ac",
}, // },
}), // }),
], ],
content: props.content, content: props.content,
@ -65,15 +91,13 @@ function Editor(props: Editor) {
<EditorTop> <EditorTop>
<EditorDocSection> <EditorDocSection>
<EditorInfoWrapper> <EditorInfoWrapper>
<EditorInfoLearnHouseLogo width={23} height={23} src={learnhouseIcon} alt="" /> <EditorInfoLearnHouseLogo width={25} height={25} src={learnhouseIcon} alt="" />
<EditorInfoThumbnail src={`${getBackendUrl()}content/uploads/img/${props.course.course.thumbnail}`} alt=""></EditorInfoThumbnail> <EditorInfoThumbnail src={`${getBackendUrl()}content/uploads/img/${props.course.course.thumbnail}`} alt=""></EditorInfoThumbnail>
<EditorInfoDocName> <EditorInfoDocName>
{" "} {" "}
<b>{props.course.course.name}</b> <SlashIcon /> {props.element.name}{" "} <b>{props.course.course.name}</b> <SlashIcon /> {props.element.name}{" "}
</EditorInfoDocName> </EditorInfoDocName>
<EditorSaveButton onClick={() => props.setContent(editor.getJSON())}> <EditorSaveButton onClick={() => props.setContent(editor.getJSON())}>Save</EditorSaveButton>
Save
</EditorSaveButton>
</EditorInfoWrapper> </EditorInfoWrapper>
<EditorButtonsWrapper> <EditorButtonsWrapper>
<ToolbarButtons editor={editor} /> <ToolbarButtons editor={editor} />
@ -90,7 +114,6 @@ function Editor(props: Editor) {
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.99 }} initial={{ opacity: 0, scale: 0.99 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
key="modal"
transition={{ transition={{
type: "spring", type: "spring",
stiffness: 360, stiffness: 360,
@ -106,6 +129,7 @@ function Editor(props: Editor) {
</Page> </Page>
); );
} }
const Page = styled.div` const Page = styled.div`
height: 100vh; height: 100vh;
width: 100%; width: 100%;
@ -119,7 +143,7 @@ const Page = styled.div`
background-size: 50px 50px; background-size: 50px 50px;
background-attachment: fixed; background-attachment: fixed;
background-repeat: repeat; background-repeat: repeat;
` `;
const EditorTop = styled.div` const EditorTop = styled.div`
background-color: #ffffffb8; background-color: #ffffffb8;
@ -224,7 +248,7 @@ const EditorInfoThumbnail = styled.img`
} }
`; `;
const EditorContentWrapper = styled.div` export const EditorContentWrapper = styled.div`
margin: 40px; margin: 40px;
margin-top: 90px; margin-top: 90px;
background-color: white; background-color: white;
@ -236,7 +260,8 @@ const EditorContentWrapper = styled.div`
.ProseMirror { .ProseMirror {
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-bottom: 6px; padding-bottom: 20px;
padding-top: 1px; padding-top: 1px;
&:focus { &:focus {
outline: none !important; outline: none !important;
@ -244,6 +269,17 @@ const EditorContentWrapper = styled.div`
box-shadow: none !important; 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; export default Editor;

View file

@ -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);
},
});

View file

@ -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 (
<NodeViewWrapper>
<InfoCalloutWrapper contentEditable={props.extension.options.editable}>
<AlertCircle /> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
</InfoCalloutWrapper>
</NodeViewWrapper>
);
}
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;

View file

@ -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);
},
});

View file

@ -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 (
<NodeViewWrapper>
<CalloutWrapper contentEditable={props.extension.options.editable}>
<AlertTriangle/> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
</CalloutWrapper>
</NodeViewWrapper>
);
}
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;

View file

@ -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);
},
});

View file

@ -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<any>) => {
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 (
<NodeViewWrapper className="block-image">
{!fileObject && (
<BlockImageWrapper contentEditable={props.extension.options.editable}>
<div>
<Image color="#e1e0e0" size={50} />
<br />
</div>
<input onChange={handleImageChange} type="file" name="" id="" />
<br />
<button onClick={handleSubmit}>Submit</button>
</BlockImageWrapper>
)}
{fileObject && (
<BlockImage>
<img
src={`${getBackendUrl()}content/uploads/files/pictures/${props.extension.options.element.element_id}/${fileObject.file_id}.${
fileObject.file_format
}`}
alt=""
/>
</BlockImage>
)}
{isLoading && (
<div>
<AlertTriangle color="#e1e0e0" size={50} />
</div>
)}
</NodeViewWrapper>
);
}
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``;

View file

@ -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);
},
});

View file

@ -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<any>) => {
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 (
<NodeViewWrapper className="block-video">
{!fileObject && (
<BlockVideoWrapper contentEditable={props.extension.options.editable}>
<div>
<Video color="#e1e0e0" size={50} />
<br />
</div>
<input onChange={handleVideoChange} type="file" name="" id="" />
<br />
<button onClick={handleSubmit}>Submit</button>
</BlockVideoWrapper>
)}
{fileObject && (
<BlockVideo>
<video
controls
src={`${getBackendUrl()}content/uploads/files/videos/${props.extension.options.element.element_id}/${fileObject.file_id}.${
fileObject.file_format
}`}
></video>
</BlockVideo>
)}
{isLoading && (
<div>
<AlertTriangle color="#e1e0e0" size={50} />
</div>
)}
</NodeViewWrapper>
);
}
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;

View file

@ -1,11 +1,26 @@
import styled from "styled-components"; 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) => { export const ToolbarButtons = ({ editor }: any) => {
if (!editor) { if (!editor) {
return null; return null;
} }
// YouTube extension
const addYoutubeVideo = () => {
const url = prompt("Enter YouTube URL");
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 640,
height: 480,
});
}
};
return ( return (
<ToolButtonsWrapper> <ToolButtonsWrapper>
<ToolBtn onClick={() => editor.chain().focus().undo().run()}> <ToolBtn onClick={() => editor.chain().focus().undo().run()}>
@ -23,7 +38,15 @@ export const ToolbarButtons = ({ editor }: any) => {
<ToolBtn onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}> <ToolBtn onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}>
<StrikethroughIcon /> <StrikethroughIcon />
</ToolBtn> </ToolBtn>
<ToolSelect onChange={(e) => editor.chain().focus().toggleHeading({ level: parseInt(e.target.value) }).run() }> <ToolSelect
onChange={(e) =>
editor
.chain()
.focus()
.toggleHeading({ level: parseInt(e.target.value) })
.run()
}
>
<option value="1">Heading 1</option> <option value="1">Heading 1</option>
<option value="2">Heading 2</option> <option value="2">Heading 2</option>
<option value="3">Heading 3</option> <option value="3">Heading 3</option>
@ -31,6 +54,42 @@ export const ToolbarButtons = ({ editor }: any) => {
<option value="5">Heading 5</option> <option value="5">Heading 5</option>
<option value="6">Heading 6</option> <option value="6">Heading 6</option>
</ToolSelect> </ToolSelect>
{/* TODO: fix this : toggling only works one-way */}
<ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutWarning").run()}>
<AlertTriangle size={15} />
</ToolBtn>
<ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutInfo").run()}>
<AlertCircle size={15} />
</ToolBtn>
<ToolBtn
onClick={() =>
editor
.chain()
.focus()
.insertContent({
type: "blockImage",
})
.run()
}
>
<ImagePlus size={15} />
</ToolBtn>
<ToolBtn
onClick={() =>
editor
.chain()
.focus()
.insertContent({
type: "blockVideo",
})
.run()
}
>
<Video size={15} />
</ToolBtn>
<ToolBtn onClick={() => addYoutubeVideo()}>
<Youtube size={15} />
</ToolBtn>
</ToolButtonsWrapper> </ToolButtonsWrapper>
); );
}; };
@ -49,10 +108,13 @@ const ToolBtn = styled.div`
width: 25px; width: 25px;
height: 25px; height: 25px;
padding: 5px; padding: 5px;
font-size: 5px;
margin-right: 5px; margin-right: 5px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
svg {
padding: 1px;
}
&.is-active { &.is-active {
background: rgba(176, 176, 176, 0.5); background: rgba(176, 176, 176, 0.5);

View file

@ -39,7 +39,7 @@ const AuthProvider = (props: any) => {
} }
} else { } else {
setAuth({ access_token, isAuthenticated: false, userInfo, isLoading }); setAuth({ access_token, isAuthenticated: false, userInfo, isLoading });
router.push("/login"); //router.push("/login");
} }
} catch (error) { } catch (error) {
router.push("/"); router.push("/");

View file

@ -12,11 +12,13 @@
"@radix-ui/react-icons": "^1.1.1", "@radix-ui/react-icons": "^1.1.1",
"@tiptap/extension-collaboration": "^2.0.0-beta.199", "@tiptap/extension-collaboration": "^2.0.0-beta.199",
"@tiptap/extension-collaboration-cursor": "^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/html": "^2.0.0-beta.202",
"@tiptap/react": "^2.0.0-beta.199", "@tiptap/react": "^2.0.0-beta.199",
"@tiptap/starter-kit": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"framer-motion": "^7.3.6", "framer-motion": "^7.3.6",
"lucide-react": "^0.104.1",
"next": "12.3.1", "next": "12.3.1",
"react": "18.2.0", "react": "18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -1296,6 +1298,18 @@
"@tiptap/core": "^2.0.0-beta.193" "@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": { "node_modules/@tiptap/html": {
"version": "2.0.0-beta.202", "version": "2.0.0-beta.202",
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz", "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz",
@ -3532,6 +3546,15 @@
"node": ">=10" "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": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -5961,6 +5984,12 @@
"integrity": "sha512-ntOqEhkBjDHrdzxvpPe4U1JB5GgE9/yyWqWdgzSL9lpSndRTJN1xQLOmyuv0qsLqOgBHn1YITHvaxPb3t8FrFw==", "integrity": "sha512-ntOqEhkBjDHrdzxvpPe4U1JB5GgE9/yyWqWdgzSL9lpSndRTJN1xQLOmyuv0qsLqOgBHn1YITHvaxPb3t8FrFw==",
"requires": {} "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": { "@tiptap/html": {
"version": "2.0.0-beta.202", "version": "2.0.0-beta.202",
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz", "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz",
@ -7577,6 +7606,12 @@
"yallist": "^4.0.0" "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": { "memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",

View file

@ -13,11 +13,13 @@
"@radix-ui/react-icons": "^1.1.1", "@radix-ui/react-icons": "^1.1.1",
"@tiptap/extension-collaboration": "^2.0.0-beta.199", "@tiptap/extension-collaboration": "^2.0.0-beta.199",
"@tiptap/extension-collaboration-cursor": "^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/html": "^2.0.0-beta.202",
"@tiptap/react": "^2.0.0-beta.199", "@tiptap/react": "^2.0.0-beta.199",
"@tiptap/starter-kit": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"framer-motion": "^7.3.6", "framer-motion": "^7.3.6",
"lucide-react": "^0.104.1",
"next": "12.3.1", "next": "12.3.1",
"react": "18.2.0", "react": "18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -4,12 +4,10 @@ import styled from "styled-components";
import learnhouseBigIcon from "public/learnhouse_bigicon.png"; import learnhouseBigIcon from "public/learnhouse_bigicon.png";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { PreAlphaLabel } from "../components//UI/Layout";
const Home: NextPage = () => { const Home: NextPage = () => {
return ( return (
<HomePage> <HomePage>
<PreAlphaLabel>🚧 Pre-Alpha</PreAlphaLabel>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}

View file

@ -1,14 +1,9 @@
import Bold from "@tiptap/extension-bold";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import StarterKit from "@tiptap/starter-kit";
import Text from "@tiptap/extension-text";
import { generateHTML } from "@tiptap/html";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import Layout from "../../../../../../../components//UI/Layout"; import Layout from "../../../../../../../components//UI/Layout";
import { getElement } from "../../../../../../../services/courses/elements"; import { getElement } from "../../../../../../../services/courses/elements";
import { getBackendUrl } from "../../../../../../../services/config"; import { getBackendUrl } from "../../../../../../../services/config";
import Canva from "../../../../../../../components/Canva/Canva";
function ElementPage() { function ElementPage() {
const router = useRouter(); const router = useRouter();
@ -30,35 +25,6 @@ function ElementPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]); }, [router.isReady]);
const output = useMemo(() => {
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 ( return (
<Layout> <Layout>
{isLoading ? ( {isLoading ? (
@ -69,7 +35,7 @@ function ElementPage() {
<h1>{element.name} </h1> <h1>{element.name} </h1>
<hr /> <hr />
{element.type == "dynamic" && <div dangerouslySetInnerHTML={{ __html: output } as any}></div>} {element.type == "dynamic" && <Canva content= {element.content} element={element}/>}
{/* todo : use apis & streams instead of this */} {/* todo : use apis & streams instead of this */}
{element.type == "video" && ( {element.type == "video" && (
<video controls src={`${getBackendUrl()}content/uploads/video/${element.content.video.element_id}/${element.content.video.filename}`}></video> <video controls src={`${getBackendUrl()}content/uploads/video/${element.content.video.element_id}/${element.content.video.filename}`}></video>

View file

@ -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));
}

View file

@ -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));
}

View file

@ -16,5 +16,12 @@
"incremental": true "incremental": true
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"paths": {
"@components/*": ["components/*"],
"@public/*": ["public/*"],
"@images/*": ["public/img/*"],
"@services/*": ["services/*"],
"@editor/*": ["components/Editor/*"]
},
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter 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 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(houses.router, prefix="/houses", tags=["houses"])
global_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) global_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"])
global_router.include_router(roles.router, prefix="/roles", tags=["roles"]) 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(courses.router, prefix="/courses", tags=["courses"])
global_router.include_router(chapters.router, prefix="/chapters", tags=["chapters"]) global_router.include_router(chapters.router, prefix="/chapters", tags=["chapters"])
global_router.include_router(elements.router, prefix="/elements", tags=["elements"]) global_router.include_router(elements.router, prefix="/elements", tags=["elements"])

View file

@ -47,7 +47,6 @@ async def api_delete_element(element_id: str, current_user: PublicUser = Depends
# Video Element # Video Element
@router.post("/video") @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): 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):
""" """

41
src/routers/files.py Normal file
View file

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

View file

@ -1,14 +1,13 @@
import json import json
import os
from typing import List from typing import List
from uuid import uuid4 from uuid import uuid4
from pydantic import BaseModel from pydantic import BaseModel
from src.services.courses.elements.elements import ElementInDB from src.services.courses.elements.elements import ElementInDB
from src.services.uploads import upload_thumbnail from src.services.courses.thumbnails import upload_thumbnail
from src.services.users import PublicUser, User from src.services.users import PublicUser
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 * 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 from datetime import datetime
#### Classes #################################################### #### Classes ####################################################
@ -79,7 +78,6 @@ async def get_course_meta(course_id: str, current_user: PublicUser):
course = courses.find_one({"course_id": course_id}) course = courses.find_one({"course_id": course_id})
elements = learnhouseDB["elements"] elements = learnhouseDB["elements"]
# verify course rights # verify course rights
await verify_rights(course_id, current_user, "read") 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( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Course does not exist") status_code=status.HTTP_409_CONFLICT, detail="Course does not exist")
coursechapters = coursechapters.find( coursechapters = coursechapters.find(
{"course_id": course_id}).sort("name", 1) {"course_id": course_id}).sort("name", 1)

View file

@ -1,21 +1,7 @@
import os import os
async def upload_thumbnail(thumbnail_file, name_in_disk): async def upload_video(video_file, element_id):
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):
contents = video_file.file.read() contents = video_file.file.read()
video_format = video_file.filename.split(".")[-1] video_format = video_file.filename.split(".")[-1]
# create folder # create folder

View file

@ -1,10 +1,10 @@
from pydantic import BaseModel 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.security import verify_user_rights_with_roles
from src.services.uploads import upload_video from src.services.courses.elements.uploads.videos import upload_video
from src.services.users import PublicUser, User from src.services.users import PublicUser
from src.services.courses.elements.elements import ElementInDB, Element from src.services.courses.elements.elements import ElementInDB
from fastapi import FastAPI, HTTPException, status, Request, Response, BackgroundTasks, UploadFile, File from fastapi import HTTPException, status, UploadFile
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
@ -48,7 +48,7 @@ async def create_video_element(name: str, coursechapter_id: str, current_user:
print("uploading video") print("uploading video")
# get videofile format # 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 # todo : choose whether to update the chapter or not
# update chapter # update chapter

View file

@ -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()

View file

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

View file

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