diff --git a/apps/api/src/services/courses/activities/uploads/sub_file.py b/apps/api/src/services/courses/activities/uploads/sub_file.py index 9ebd46f5..c6d16e74 100644 --- a/apps/api/src/services/courses/activities/uploads/sub_file.py +++ b/apps/api/src/services/courses/activities/uploads/sub_file.py @@ -19,5 +19,5 @@ async def upload_submission_file( org_uuid, contents, f"{name_in_disk}", - ["pdf", "docx", "mp4", "jpg", "jpeg", "png", "pptx"], + ["pdf", "docx", "mp4", "jpg", "jpeg", "png", "pptx", "zip"], ) diff --git a/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py b/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py index 56981cc0..af9814ef 100644 --- a/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py +++ b/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py @@ -19,5 +19,5 @@ async def upload_reference_file( org_uuid, contents, f"{name_in_disk}", - ["pdf", "docx", "mp4", "jpg", "jpeg", "png", "pptx"], + ["pdf", "docx", "mp4", "jpg", "jpeg", "png", "pptx", "zip"], ) diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index fe55443f..3fe99771 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -20,7 +20,7 @@ export default function NotFound() { 404!

- We are very sorry for the inconvinience. It looks like you're trying to + We are very sorry for the inconvenience. It looks like you're trying to

access a page that has been deleted or never existed before

diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx index 55d44598..d9221af0 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx @@ -90,7 +90,7 @@ export function AssignmentTaskGeneralEdit() {
-

Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx

+

Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx, zip

diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/TaskTypes/TaskFileObject.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/TaskTypes/TaskFileObject.tsx index 946fae70..53c8b4f5 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/TaskTypes/TaskFileObject.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/TaskTypes/TaskFileObject.tsx @@ -263,7 +263,7 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta )}
-

Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx

+

Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx, zip

{isLoading ? (
diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 980fd6bb..ba264bd2 100644 --- a/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -3,10 +3,11 @@ import { useOrg } from '@components/Contexts/OrgContext' import { getAPIUrl } from '@services/config/config' import { updateCourseThumbnail } from '@services/courses/courses' import { getCourseThumbnailMediaDirectory } from '@services/media/media' -import { ArrowBigUpDash, UploadCloud } from 'lucide-react' +import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react' import { useLHSession } from '@components/Contexts/LHSessionContext' -import React from 'react' +import React, { useState } from 'react' import { mutate } from 'swr' +import UnsplashImagePicker from './UnsplashImagePicker' function ThumbnailUpdate() { const course = useCourse() as any @@ -15,10 +16,24 @@ function ThumbnailUpdate() { const [localThumbnail, setLocalThumbnail] = React.useState(null) as any const [isLoading, setIsLoading] = React.useState(false) as any const [error, setError] = React.useState('') as any + const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) const handleFileChange = async (event: any) => { const file = event.target.files[0] setLocalThumbnail(file) + await updateThumbnail(file) + } + + const handleUnsplashSelect = async (imageUrl: string) => { + setIsLoading(true) + const response = await fetch(imageUrl) + const blob = await response.blob() + const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' }) + setLocalThumbnail(file) + await updateThumbnail(file) + } + + const updateThumbnail = async (file: File) => { setIsLoading(true) const res = await updateCourseThumbnail( course.courseStructure.course_uuid, @@ -49,8 +64,7 @@ function ThumbnailUpdate() { {localThumbnail ? ( ) : ( {isLoading ? (
- -
+
Uploading
) : ( -
+
+
)}
+ {showUnsplashPicker && ( + setShowUnsplashPicker(false)} + /> + )}
) } -export default ThumbnailUpdate +export default ThumbnailUpdate \ No newline at end of file diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx b/apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx new file mode 100644 index 00000000..662974af --- /dev/null +++ b/apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { createApi } from 'unsplash-js'; +import { Search, X, Cpu, Briefcase, GraduationCap, Heart, Palette, Plane, Utensils, + Dumbbell, Music, Shirt, Book, Building, Bike, Camera, Microscope, Coins, Coffee, Gamepad, + Flower} from 'lucide-react'; + +const unsplash = createApi({ + accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as string, +}); + +const IMAGES_PER_PAGE = 20; + +const predefinedLabels = [ + { name: 'Nature', icon: Flower }, + { name: 'Technology', icon: Cpu }, + { name: 'Business', icon: Briefcase }, + { name: 'Education', icon: GraduationCap }, + { name: 'Health', icon: Heart }, + { name: 'Art', icon: Palette }, + { name: 'Science', icon: Microscope }, + { name: 'Travel', icon: Plane }, + { name: 'Food', icon: Utensils }, + { name: 'Sports', icon: Dumbbell }, + { name: 'Music', icon: Music }, + { name: 'Fashion', icon: Shirt }, + { name: 'History', icon: Book }, + { name: 'Architecture', icon: Building }, + { name: 'Fitness', icon: Bike }, + { name: 'Photography', icon: Camera }, + { name: 'Biology', icon: Microscope }, + { name: 'Finance', icon: Coins }, + { name: 'Lifestyle', icon: Coffee }, + { name: 'Gaming', icon: Gamepad }, +]; + +interface UnsplashImagePickerProps { + onSelect: (imageUrl: string) => void; + onClose: () => void; +} + +const UnsplashImagePicker: React.FC = ({ onSelect, onClose }) => { + const [query, setQuery] = useState(''); + const [images, setImages] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + + const fetchImages = useCallback(async (searchQuery: string, pageNum: number) => { + setLoading(true); + try { + const result = await unsplash.search.getPhotos({ + query: searchQuery, + page: pageNum, + perPage: IMAGES_PER_PAGE, + }); + if (result && result.response) { + setImages(prevImages => pageNum === 1 ? result.response.results : [...prevImages, ...result.response.results]); + } else { + console.error('Unexpected response structure:', result); + } + } catch (error) { + console.error('Error fetching images:', error); + } finally { + setLoading(false); + } + }, []); + + const debouncedFetchImages = useCallback( + debounce((searchQuery: string) => { + setPage(1); + fetchImages(searchQuery, 1); + }, 300), + [fetchImages] + ); + + useEffect(() => { + if (query) { + debouncedFetchImages(query); + } + }, [query, debouncedFetchImages]); + + const handleSearch = (e: React.ChangeEvent) => { + setQuery(e.target.value); + }; + + const handleLabelClick = (label: string) => { + setQuery(label); + }; + + const handleLoadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchImages(query, nextPage); + }; + + const handleImageSelect = (imageUrl: string) => { + onSelect(imageUrl); + onClose(); + }; + + return ( +
+
+
+

Choose an image from Unsplash

+ +
+
+ + +
+
+ {predefinedLabels.map(label => ( + + ))} +
+
+ {images.map(image => ( +
+ {image.alt_description} handleImageSelect(image.urls.full)} + /> +
+ ))} +
+ {loading &&

Loading...

} + {!loading && images.length > 0 && ( + + )} +
+
+ ); +}; + +// Custom debounce function +const debounce = (func: Function, delay: number) => { + let timeoutId: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; +}; + +export default UnsplashImagePicker; \ No newline at end of file diff --git a/apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx b/apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx index ce8ef647..691783d4 100644 --- a/apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx +++ b/apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx @@ -175,7 +175,7 @@ function UserEditGeneral() { } > - Change Thumbnail + Change Avatar )} diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index e5dbf880..86460612 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -24,6 +24,9 @@ import java from 'highlight.js/lib/languages/java' import { NoTextInput } from '@components/Objects/Editor/Extensions/NoTextInput/NoTextInput' import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext' import AICanvaToolkit from './AI/AICanvaToolkit' +import EmbedObjects from '@components/Objects/Editor/Extensions/EmbedObjects/EmbedObjects' +import Badges from '@components/Objects/Editor/Extensions/Badges/Badges' +import Buttons from '@components/Objects/Editor/Extensions/Buttons/Buttons' interface Editor { content: string @@ -85,6 +88,18 @@ function Canva(props: Editor) { CodeBlockLowlight.configure({ lowlight, }), + EmbedObjects.configure({ + editable: isEditable, + activity: props.activity, + }), + Badges.configure({ + editable: isEditable, + activity: props.activity, + }), + Buttons.configure({ + editable: isEditable, + activity: props.activity, + }), ], content: props.content, diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 185c8907..b15a8b4f 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -48,6 +48,9 @@ import Collaboration from '@tiptap/extension-collaboration' import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import ActiveAvatars from './ActiveAvatars' import { getUriWithOrg } from '@services/config/config' +import EmbedObjects from './Extensions/EmbedObjects/EmbedObjects' +import Badges from './Extensions/Badges/Badges' +import Buttons from './Extensions/Buttons/Buttons' interface Editor { content: string @@ -133,6 +136,18 @@ function Editor(props: Editor) { CodeBlockLowlight.configure({ lowlight, }), + EmbedObjects.configure({ + editable: true, + activity: props.activity, + }), + Badges.configure({ + editable: true, + activity: props.activity, + }), + Buttons.configure({ + editable: true, + activity: props.activity, + }), // Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true ...(props.isCollabEnabledOnThisOrg ? [ diff --git a/apps/web/components/Objects/Editor/Extensions/Badges/Badges.ts b/apps/web/components/Objects/Editor/Extensions/Badges/Badges.ts new file mode 100644 index 00000000..2d2dda19 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Badges/Badges.ts @@ -0,0 +1,39 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { mergeAttributes, Node } from "@tiptap/core"; +import BadgesExtension from "@/components/Objects/Editor/Extensions/Badges/BadgesExtension"; + +export default Node.create({ + name: "badge", + group: "block", + draggable: true, + content: "text*", + + // TODO : multi line support + + addAttributes() { + return { + color: { + default: 'sky', + }, + emoji: { + default: '💡', + }, + }; + }, + + parseHTML() { + return [ + { + tag: "badge", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["badge", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(BadgesExtension); + }, +}); diff --git a/apps/web/components/Objects/Editor/Extensions/Badges/BadgesExtension.tsx b/apps/web/components/Objects/Editor/Extensions/Badges/BadgesExtension.tsx new file mode 100644 index 00000000..888eb730 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Badges/BadgesExtension.tsx @@ -0,0 +1,221 @@ +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react' +import React, { useState, useRef, useEffect } from 'react' +import Picker from '@emoji-mart/react' +import { ArrowRight, ChevronDown, ChevronRight, EllipsisVertical, Palette, Plus } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' + +const BadgesExtension: React.FC = (props: any) => { + const [color, setColor] = useState(props.node.attrs.color) + const [emoji, setEmoji] = useState(props.node.attrs.emoji) + const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [showColorPicker, setShowColorPicker] = useState(false) + const [showPredefinedCallouts, setShowPredefinedCallouts] = useState(false) + const pickerRef = useRef(null) + const colorPickerRef = useRef(null) + const editorState = useEditorProvider() as any + const isEditable = editorState.isEditable + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + (pickerRef.current && !pickerRef.current.contains(event.target as Node)) || + (colorPickerRef.current && !colorPickerRef.current.contains(event.target as Node)) + ) { + setShowEmojiPicker(false) + setShowColorPicker(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const handleEmojiSelect = (emoji: any) => { + setEmoji(emoji.native) + setShowEmojiPicker(false) + props.updateAttributes({ + emoji: emoji.native, + }) + } + + const handleColorSelect = (selectedColor: string) => { + setColor(selectedColor) + setShowColorPicker(false) + props.updateAttributes({ + color: selectedColor, + }) + } + + const handlePredefinedBadgeSelect = (badge: typeof predefinedBadges[0]) => { + setEmoji(badge.emoji) + setColor(badge.color) + + props.updateAttributes({ + emoji: badge.emoji, + color: badge.color, + }) + + // Insert the predefined content + const { editor } = props + if (editor) { + editor.commands.setTextSelection({ from: props.getPos() + 1, to: props.getPos() + props.node.nodeSize - 1 }) + editor.commands.insertContent(badge.content) + } + + setShowPredefinedCallouts(false) + } + + const colors = ['sky', 'green', 'yellow', 'red', 'purple', 'teal', 'amber', 'indigo', 'neutral'] + const predefinedBadges = [ + { + emoji: '📝', + color: 'sky', + content: 'Key Concept' + }, + { + emoji: '💡', + color: 'yellow', + content: 'Example' + }, + { + emoji: '🔍', + color: 'teal', + content: 'Deep Dive' + }, + { + emoji: '⚠️', + color: 'red', + content: 'Important Note' + }, + { + emoji: '🧠', + color: 'purple', + content: 'Remember This' + }, + { + emoji: '🏋️', + color: 'green', + content: 'Exercise' + }, + { + emoji: '🎯', + color: 'amber', + content: 'Learning Objective' + }, + { + emoji: '📚', + color: 'indigo', + content: 'Further Reading' + }, + { + emoji: '💬', + color: 'neutral', + content: 'Discussion Topic' + } + ] + + const getBadgeColor = (color: string) => { + switch (color) { + case 'sky': return 'bg-sky-400 text-sky-50'; + case 'green': return 'bg-green-400 text-green-50'; + case 'yellow': return 'bg-yellow-400 text-black'; + case 'red': return 'bg-red-500 text-red-50'; + case 'purple': return 'bg-purple-400 text-purple-50'; + case 'pink': return 'bg-pink-400 text-pink-50'; + case 'teal': return 'bg-teal-400 text-teal-900'; + case 'amber': return 'bg-amber-600 text-amber-100'; + case 'indigo': return 'bg-indigo-400 text-indigo-50'; + case 'neutral': return 'bg-neutral-800 text-white'; + default: return 'bg-sky-400 text-white'; + } + } + + return ( + +
+
+
+ {emoji} + {isEditable && ( + + )} +
+ + + {isEditable && ( +
+ + {showColorPicker && ( +
+
+ {colors.map((c) => ( +
+
+ )} +
+ )} +
+ {isEditable && ( + + )} + {isEditable && showPredefinedCallouts && ( +
+ {predefinedBadges.map((badge, index) => ( + + ))} +
+ )} +
+ + {isEditable && showEmojiPicker && ( +
+ +
+ )} + + +
+ ) +} + +export default BadgesExtension; diff --git a/apps/web/components/Objects/Editor/Extensions/Buttons/Buttons.ts b/apps/web/components/Objects/Editor/Extensions/Buttons/Buttons.ts new file mode 100644 index 00000000..298bbaa7 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Buttons/Buttons.ts @@ -0,0 +1,43 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { mergeAttributes, Node } from "@tiptap/core"; +import ButtonsExtension from "./ButtonsExtension"; + +export default Node.create({ + name: "button", + group: "block", + draggable: true, + content: "text*", + + addAttributes() { + return { + emoji: { + default: '🔗', + }, + link: { + default: '', + }, + color: { + default: 'blue', + }, + alignment: { + default: 'left', + }, + }; + }, + + parseHTML() { + return [ + { + tag: "button-block", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["button-block", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ButtonsExtension); + }, +}); \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Extensions/Buttons/ButtonsExtension.tsx b/apps/web/components/Objects/Editor/Extensions/Buttons/ButtonsExtension.tsx new file mode 100644 index 00000000..63c2e65a --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Buttons/ButtonsExtension.tsx @@ -0,0 +1,168 @@ +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react' +import React, { useState, useRef, useEffect } from 'react' +import Picker from '@emoji-mart/react' +import { ArrowRight, ChevronDown, Link, AlignLeft, AlignCenter, AlignRight, Palette } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' + +const ButtonsExtension: React.FC = (props: any) => { + const [emoji, setEmoji] = useState(props.node.attrs.emoji) + const [link, setLink] = useState(props.node.attrs.link) + const [alignment, setAlignment] = useState(props.node.attrs.alignment) + const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [showLinkInput, setShowLinkInput] = useState(false) + const [color, setColor] = useState(props.node.attrs.color || 'blue') + const [showColorPicker, setShowColorPicker] = useState(false) + const pickerRef = useRef(null) + const linkInputRef = useRef(null) + const colorPickerRef = useRef(null) + const editorState = useEditorProvider() as any + const isEditable = editorState.isEditable + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + setShowEmojiPicker(false) + } + if (linkInputRef.current && !linkInputRef.current.contains(event.target as Node)) { + setShowLinkInput(false) + } + if (colorPickerRef.current && !colorPickerRef.current.contains(event.target as Node)) { + setShowColorPicker(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const handleEmojiSelect = (emoji: any) => { + setEmoji(emoji.native) + setShowEmojiPicker(false) + props.updateAttributes({ + emoji: emoji.native, + }) + } + + const handleLinkChange = (e: React.ChangeEvent) => { + setLink(e.target.value) + props.updateAttributes({ + link: e.target.value, + }) + } + + const handleAlignmentChange = (newAlignment: 'left' | 'center' | 'right') => { + setAlignment(newAlignment) + props.updateAttributes({ + alignment: newAlignment, + }) + } + + const getAlignmentClass = () => { + switch (alignment) { + case 'left': return 'text-left'; + case 'center': return 'text-center'; + case 'right': return 'text-right'; + default: return 'text-left'; + } + } + + const handleColorSelect = (selectedColor: string) => { + setColor(selectedColor) + setShowColorPicker(false) + props.updateAttributes({ + color: selectedColor, + }) + } + + const getButtonColor = (color: string) => { + switch (color) { + case 'sky': return 'bg-sky-500 hover:bg-sky-600'; + case 'green': return 'bg-green-500 hover:bg-green-600'; + case 'yellow': return 'bg-yellow-500 hover:bg-yellow-600'; + case 'red': return 'bg-red-500 hover:bg-red-600'; + case 'purple': return 'bg-purple-500 hover:bg-purple-600'; + case 'teal': return 'bg-teal-500 hover:bg-teal-600'; + case 'amber': return 'bg-amber-500 hover:bg-amber-600'; + case 'indigo': return 'bg-indigo-500 hover:bg-indigo-600'; + case 'neutral': return 'bg-neutral-500 hover:bg-neutral-600'; + default: return 'bg-blue-500 hover:bg-blue-600'; + } + } + + const colors = ['sky', 'green', 'yellow', 'red', 'purple', 'teal', 'amber', 'indigo', 'neutral', 'blue'] + + return ( + +
+ + {isEditable && ( +
+ + + + + + +
+ )} +
+ {isEditable && showEmojiPicker && ( +
+ +
+ )} + {isEditable && showLinkInput && ( + + )} + {isEditable && showColorPicker && ( +
+
+ {colors.map((c) => ( +
+
+ )} +
+ ) +} + +export default ButtonsExtension diff --git a/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjects.ts b/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjects.ts new file mode 100644 index 00000000..32d8eb4f --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjects.ts @@ -0,0 +1,49 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import EmbedObjectsComponent from './EmbedObjectsComponent' + + +export default Node.create({ + name: 'blockEmbed', + group: 'block', + + addAttributes() { + return { + embedUrl: { + default: null, + }, + embedCode: { + default: null, + }, + embedType: { + default: null, + }, + embedHeight: { + default: 300, + }, + embedWidth: { + default: '100%', + }, + alignment: { + default: 'left', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'block-embed', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['block-embed', mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(EmbedObjectsComponent) + }, + +}) \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjectsComponent.tsx b/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjectsComponent.tsx new file mode 100644 index 00000000..1cf77791 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/EmbedObjects/EmbedObjectsComponent.tsx @@ -0,0 +1,212 @@ +import { NodeViewWrapper } from '@tiptap/react' +import React, { useState, useRef, useEffect } from 'react' +import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react' +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' +import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy } from '@icons-pack/react-simple-icons' +import { useRouter } from 'next/navigation' +import DOMPurify from 'dompurify' + +function EmbedObjectsComponent(props: any) { + const [embedType, setEmbedType] = useState<'url' | 'code'>(props.node.attrs.embedType || 'url') + const [embedUrl, setEmbedUrl] = useState(props.node.attrs.embedUrl || '') + const [embedCode, setEmbedCode] = useState(props.node.attrs.embedCode || '') + const [embedHeight, setEmbedHeight] = useState(props.node.attrs.embedHeight || 300) + const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%') + const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left') + + const resizeRef = useRef(null) + const editorState = useEditorProvider() as any + const isEditable = editorState.isEditable + const router = useRouter() + + const supportedProducts = [ + { name: 'GitHub', icon: SiGithub, color: '#181717', guide: 'https://emgithub.com/' }, + { name: 'Replit', icon: SiReplit, color: '#F26207', guide: 'https://docs.replit.com/hosting/embedding-repls' }, + { name: 'Spotify', icon: SiSpotify, color: '#1DB954', guide: 'https://developer.spotify.com/documentation/embeds' }, + { name: 'Loom', icon: SiLoom, color: '#625DF5', guide: 'https://support.loom.com/hc/en-us/articles/360002208317-How-to-embed-your-video-into-a-webpage' }, + { name: 'GMaps', icon: SiGooglemaps, color: '#4285F4', guide: 'https://developers.google.com/maps/documentation/embed/get-started' }, + { name: 'CodePen', icon: SiCodepen, color: '#000000', guide: 'https://blog.codepen.io/documentation/embedded-pens/' }, + { name: 'Canva', icon: SiCanva, color: '#00C4CC', guide: 'https://www.canva.com/help/article/embed-designs' }, + { name: 'Notion', icon: SiNotion, color: '#878787', guide: 'https://www.notion.so/help/embed-and-connect-other-apps#7a70ac4b5c5f4ec889e69d262e0de9e7' }, + { name: 'G Docs', icon: SiGoogledocs, color: '#4285F4', guide: 'https://support.google.com/docs/answer/183965?hl=en&co=GENIE.Platform%3DDesktop' }, + { name: 'X', icon: SiX, color: '#000000', guide: 'https://help.twitter.com/en/using-twitter/how-to-embed-a-tweet' }, + { name: 'Figma', icon: SiFigma, color: '#F24E1E', guide: 'https://help.figma.com/hc/en-us/articles/360041057214-Embed-files-and-prototypes' }, + { name: 'Giphy', icon: SiGiphy, color: '#FF6666', guide: 'https://developers.giphy.com/docs/embed/' }, + ] + + const [sanitizedEmbedCode, setSanitizedEmbedCode] = useState('') + + useEffect(() => { + if (embedType === 'code' && embedCode) { + const sanitized = DOMPurify.sanitize(embedCode, { + ADD_TAGS: ['iframe'], + ADD_ATTR: ['*'] + }) + setSanitizedEmbedCode(sanitized) + } + }, [embedCode, embedType]) + + const handleEmbedTypeChange = (type: 'url' | 'code') => { + setEmbedType(type) + props.updateAttributes({ embedType: type }) + } + + const handleUrlChange = (event: React.ChangeEvent) => { + const newUrl = event.target.value; + // Sanitize the URL + const sanitizedUrl = DOMPurify.sanitize(newUrl); + setEmbedUrl(sanitizedUrl); + props.updateAttributes({ + embedUrl: sanitizedUrl, + embedType: 'url', + }); + }; + + const handleCodeChange = (event: React.ChangeEvent) => { + const newCode = event.target.value; + setEmbedCode(newCode); + props.updateAttributes({ + embedCode: newCode, + embedType: 'code', + }); + }; + + const handleResizeStart = (event: React.MouseEvent, direction: 'horizontal' | 'vertical') => { + event.preventDefault() + const startX = event.clientX + const startY = event.clientY + const startWidth = resizeRef.current?.offsetWidth || 0 + const startHeight = resizeRef.current?.offsetHeight || 0 + + const handleMouseMove = (e: MouseEvent) => { + if (resizeRef.current) { + if (direction === 'horizontal') { + const newWidth = startWidth + e.clientX - startX + const parentWidth = resizeRef.current.parentElement?.offsetWidth || 1 + const widthPercentage = Math.min(100, Math.max(10, (newWidth / parentWidth) * 100)) + const newWidthValue = `${widthPercentage}%` + setEmbedWidth(newWidthValue) + props.updateAttributes({ embedWidth: newWidthValue }) + } else { + const newHeight = Math.max(100, startHeight + e.clientY - startY) + setEmbedHeight(newHeight) + props.updateAttributes({ embedHeight: newHeight }) + } + } + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + const handleCenterBlock = () => { + const newAlignment = alignment === 'center' ? 'left' : 'center' + setAlignment(newAlignment) + props.updateAttributes({ alignment: newAlignment }) + } + + const handleProductClick = (guide: string) => { + window.open(guide, '_blank', 'noopener,noreferrer') + } + + return ( + +
+ {embedType === 'url' && embedUrl ? ( +