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 styled from "styled-components";
import { getBackendUrl } from "../../services/config";
import { GlobeIcon, PaperPlaneIcon, SlashIcon } from "@radix-ui/react-icons";
import { SlashIcon } from "@radix-ui/react-icons";
import Avvvatars from "avvvatars-react";
// extensions
import InfoCallout from "./Extensions/Callout/Info/InfoCallout";
import WarningCallout from "./Extensions/Callout/Warning/WarningCallout";
import ImageBlock from "./Extensions/Image/ImageBlock";
import Youtube from "@tiptap/extension-youtube";
import VideoBlock from "./Extensions/Video/VideoBlock";
interface Editor {
content: string;
@ -26,23 +32,43 @@ function Editor(props: Editor) {
const auth: any = React.useContext(AuthContext);
const editor: any = useEditor({
editable: true,
extensions: [
StarterKit.configure({
// The Collaboration extension comes with its own history handling
history: false,
// history: false,
}),
InfoCallout.configure({
editable: true,
}),
WarningCallout.configure({
editable: true,
}),
ImageBlock.configure({
editable: true,
element: props.element,
}),
VideoBlock.configure({
editable: true,
element: props.element,
}),
Youtube.configure({
controls: true,
modestBranding: true,
}),
// Register the document with Tiptap
Collaboration.configure({
document: props.ydoc,
}),
// Collaboration.configure({
// document: props.ydoc,
// }),
// Register the collaboration cursor extension
CollaborationCursor.configure({
provider: props.provider,
user: {
name: auth.userInfo.username,
color: "#f783ac",
},
}),
// CollaborationCursor.configure({
// provider: props.provider,
// user: {
// name: auth.userInfo.username,
// color: "#f783ac",
// },
// }),
],
content: props.content,
@ -65,15 +91,13 @@ function Editor(props: Editor) {
<EditorTop>
<EditorDocSection>
<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>
<EditorInfoDocName>
{" "}
<b>{props.course.course.name}</b> <SlashIcon /> {props.element.name}{" "}
</EditorInfoDocName>
<EditorSaveButton onClick={() => props.setContent(editor.getJSON())}>
Save
</EditorSaveButton>
<EditorSaveButton onClick={() => props.setContent(editor.getJSON())}>Save</EditorSaveButton>
</EditorInfoWrapper>
<EditorButtonsWrapper>
<ToolbarButtons editor={editor} />
@ -90,7 +114,6 @@ function Editor(props: Editor) {
<motion.div
initial={{ opacity: 0, scale: 0.99 }}
animate={{ opacity: 1, scale: 1 }}
key="modal"
transition={{
type: "spring",
stiffness: 360,
@ -106,6 +129,7 @@ function Editor(props: Editor) {
</Page>
);
}
const Page = styled.div`
height: 100vh;
width: 100%;
@ -113,13 +137,13 @@ const Page = styled.div`
min-width: 100vw;
padding-top: 30px;
// dots background
// dots background
background-image: radial-gradient(#4744446b 1px, transparent 1px), radial-gradient(#4744446b 1px, transparent 1px);
background-position: 0 0, 25px 25px;
background-size: 50px 50px;
background-attachment: fixed;
background-repeat: repeat;
`
`;
const EditorTop = styled.div`
background-color: #ffffffb8;
@ -224,7 +248,7 @@ const EditorInfoThumbnail = styled.img`
}
`;
const EditorContentWrapper = styled.div`
export const EditorContentWrapper = styled.div`
margin: 40px;
margin-top: 90px;
background-color: white;
@ -236,7 +260,8 @@ const EditorContentWrapper = styled.div`
.ProseMirror {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 6px;
padding-bottom: 20px;
padding-top: 1px;
&:focus {
outline: none !important;
@ -244,6 +269,17 @@ const EditorContentWrapper = styled.div`
box-shadow: none !important;
}
}
iframe {
border-radius: 6px;
border: none;
min-width: 200px;
width: 100%;
height: 440px;
min-height: 200px;
display: block;
outline: 0px solid transparent;
}
`;
export default Editor;

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 { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons";
import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons";
import { AlertCircle, AlertTriangle, ImagePlus, Info, Video, Youtube } from "lucide-react";
export const ToolbarButtons = ({ editor }: any) => {
if (!editor) {
return null;
}
// YouTube extension
const addYoutubeVideo = () => {
const url = prompt("Enter YouTube URL");
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 640,
height: 480,
});
}
};
return (
<ToolButtonsWrapper>
<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" : ""}>
<StrikethroughIcon />
</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="2">Heading 2</option>
<option value="3">Heading 3</option>
@ -31,6 +54,42 @@ export const ToolbarButtons = ({ editor }: any) => {
<option value="5">Heading 5</option>
<option value="6">Heading 6</option>
</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>
);
};
@ -49,10 +108,13 @@ const ToolBtn = styled.div`
width: 25px;
height: 25px;
padding: 5px;
font-size: 5px;
margin-right: 5px;
transition: all 0.2s ease-in-out;
svg {
padding: 1px;
}
&.is-active {
background: rgba(176, 176, 176, 0.5);

View file

@ -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("/");

View file

@ -12,11 +12,13 @@
"@radix-ui/react-icons": "^1.1.1",
"@tiptap/extension-collaboration": "^2.0.0-beta.199",
"@tiptap/extension-collaboration-cursor": "^2.0.0-beta.199",
"@tiptap/extension-youtube": "^2.0.0-beta.207",
"@tiptap/html": "^2.0.0-beta.202",
"@tiptap/react": "^2.0.0-beta.199",
"@tiptap/starter-kit": "^2.0.0-beta.199",
"avvvatars-react": "^0.4.2",
"framer-motion": "^7.3.6",
"lucide-react": "^0.104.1",
"next": "12.3.1",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
@ -1296,6 +1298,18 @@
"@tiptap/core": "^2.0.0-beta.193"
}
},
"node_modules/@tiptap/extension-youtube": {
"version": "2.0.0-beta.207",
"resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.0.0-beta.207.tgz",
"integrity": "sha512-fx3adbWZWCysl2Pbw2NNbOVJ+mZT1wJ8YImwXjlM976Z0AWlWY4+O4H2avHzIHQP+t9U/jaV4K6FdcHo6EjJuQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.0.0-beta.193"
}
},
"node_modules/@tiptap/html": {
"version": "2.0.0-beta.202",
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz",
@ -3532,6 +3546,15 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "0.104.1",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.104.1.tgz",
"integrity": "sha512-BKvhulnLKmBj+6pqUN5ViYk4a5fabMgc4B0a4ZLUnbRqkDDWH3h7Iet6U4WbesJzjWauQrXUlEvQCe5XpFuRnw==",
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -5961,6 +5984,12 @@
"integrity": "sha512-ntOqEhkBjDHrdzxvpPe4U1JB5GgE9/yyWqWdgzSL9lpSndRTJN1xQLOmyuv0qsLqOgBHn1YITHvaxPb3t8FrFw==",
"requires": {}
},
"@tiptap/extension-youtube": {
"version": "2.0.0-beta.207",
"resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.0.0-beta.207.tgz",
"integrity": "sha512-fx3adbWZWCysl2Pbw2NNbOVJ+mZT1wJ8YImwXjlM976Z0AWlWY4+O4H2avHzIHQP+t9U/jaV4K6FdcHo6EjJuQ==",
"requires": {}
},
"@tiptap/html": {
"version": "2.0.0-beta.202",
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.0.0-beta.202.tgz",
@ -7577,6 +7606,12 @@
"yallist": "^4.0.0"
}
},
"lucide-react": {
"version": "0.104.1",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.104.1.tgz",
"integrity": "sha512-BKvhulnLKmBj+6pqUN5ViYk4a5fabMgc4B0a4ZLUnbRqkDDWH3h7Iet6U4WbesJzjWauQrXUlEvQCe5XpFuRnw==",
"requires": {}
},
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",

View file

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

View file

@ -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 (
<HomePage>
<PreAlphaLabel>🚧 Pre-Alpha</PreAlphaLabel>
<motion.div
initial={{ opacity: 0, y: 20 }}
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 React, { useMemo } from "react";
import Layout from "../../../../../../../components//UI/Layout";
import { getElement } from "../../../../../../../services/courses/elements";
import { getBackendUrl } from "../../../../../../../services/config";
import Canva from "../../../../../../../components/Canva/Canva";
function ElementPage() {
const router = useRouter();
@ -30,35 +25,6 @@ function ElementPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 (
<Layout>
{isLoading ? (
@ -69,7 +35,7 @@ function ElementPage() {
<h1>{element.name} </h1>
<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 */}
{element.type == "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
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"paths": {
"@components/*": ["components/*"],
"@public/*": ["public/*"],
"@images/*": ["public/img/*"],
"@services/*": ["services/*"],
"@editor/*": ["components/Editor/*"]
},
"exclude": ["node_modules"]
}

View file

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

View file

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

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

View file

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

View file

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

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