Merge pull request #320 from learnhouse/feat/editor-and-misc-updates

Editor & quality of life improvements
This commit is contained in:
Badr B. 2024-10-10 23:05:19 +02:00 committed by GitHub
commit eedc5ab329
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1397 additions and 337 deletions

View file

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

View file

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

View file

@ -20,7 +20,7 @@ export default function NotFound() {
404!
</h1>
<p className='text-lg pt-8 text-black tracking-tight font-medium leading-normal'>
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
<div>access a page that has been deleted or never existed before</div>
</p>
</div>

View file

@ -90,7 +90,7 @@ export function AssignmentTaskGeneralEdit() {
<FormLabelAndMessage label="Reference file" message={formik.errors.hint} />
<div className='flex space-x-1.5 text-xs items-center text-gray-500 '>
<Info size={16} />
<p>Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx</p>
<p>Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
</div>
</div>

View file

@ -263,7 +263,7 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
)}
<div className='flex pt-4 font-semibold space-x-1.5 text-xs items-center text-gray-500 '>
<Info size={16} />
<p>Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx</p>
<p>Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
</div>
{isLoading ? (
<div className="flex justify-center items-center">

View file

@ -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 ? (
<img
src={URL.createObjectURL(localThumbnail)}
className={`${isLoading ? 'animate-pulse' : ''
} shadow w-[200px] h-[100px] rounded-md`}
className={`${isLoading ? 'animate-pulse' : ''} shadow w-[200px] h-[100px] rounded-md`}
/>
) : (
<img
@ -65,19 +79,13 @@ function ThumbnailUpdate() {
</div>
{isLoading ? (
<div className="flex justify-center items-center">
<input
type="file"
id="fileInput"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div className="font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
</div>
) : (
<div className="flex justify-center items-center">
<div className="flex justify-center items-center space-x-2">
<input
type="file"
id="fileInput"
@ -89,12 +97,25 @@ function ThumbnailUpdate() {
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
<span>Upload Image</span>
</button>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
onClick={() => setShowUnsplashPicker(true)}
>
<ImageIcon size={16} className="mr-2" />
<span>Choose from Gallery</span>
</button>
</div>
)}
</div>
</div>
{showUnsplashPicker && (
<UnsplashImagePicker
onSelect={handleUnsplashSelect}
onClose={() => setShowUnsplashPicker(false)}
/>
)}
</div>
)
}

View file

@ -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<UnsplashImagePickerProps> = ({ onSelect, onClose }) => {
const [query, setQuery] = useState('');
const [images, setImages] = useState<any[]>([]);
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<HTMLInputElement>) => {
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-3/4 max-w-4xl max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Choose an image from Unsplash</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={24} />
</button>
</div>
<div className="relative mb-4">
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search for images..."
className="w-full p-2 pl-10 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
</div>
<div className="flex flex-wrap gap-2 mb-4">
{predefinedLabels.map(label => (
<button
key={label.name}
onClick={() => handleLabelClick(label.name)}
className="px-3 py-1 bg-neutral-100 rounded-lg hover:bg-neutral-200 nice-shadow transition-colors flex items-center gap-1 space-x-1"
>
<label.icon size={16} />
<span>{label.name}</span>
</button>
))}
</div>
<div className="grid grid-cols-3 gap-4">
{images.map(image => (
<div key={image.id} className="relative w-full pb-[56.25%]">
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute inset-0 w-full h-full object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageSelect(image.urls.full)}
/>
</div>
))}
</div>
{loading && <p className="text-center mt-4">Loading...</p>}
{!loading && images.length > 0 && (
<button
onClick={handleLoadMore}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Load More
</button>
)}
</div>
</div>
);
};
// 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;

View file

@ -175,7 +175,7 @@ function UserEditGeneral() {
}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
<span>Change Avatar</span>
</button>
</div>
)}

View file

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

View file

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

View file

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

View file

@ -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<HTMLDivElement>(null)
const colorPickerRef = useRef<HTMLDivElement>(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 (
<NodeViewWrapper>
<div className='flex space-x-2 items-center'>
<div
className={twMerge(
'flex space-x-1 py-1.5 items-center w-fit rounded-full outline outline-2 outline-white/20 px-3.5 font-semibold nice-shadow text-sm my-2',
getBadgeColor(color)
)}
>
<div className="flex items-center justify-center space-x-1">
<span className='text'>{emoji}</span>
{isEditable && (
<button onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
<ChevronDown size={14} />
</button>
)}
</div>
<NodeViewContent
contentEditable={isEditable}
className="content capitalize text tracking-wide "
>
</NodeViewContent>
{isEditable && (
<div className="flex items-center justify-center space-x-2 relative">
<button onClick={() => setShowColorPicker(!showColorPicker)}>
<Palette size={14} />
</button>
{showColorPicker && (
<div ref={colorPickerRef} className="absolute left-full ml-2 p-2 bg-white rounded-full nice-shadow">
<div className="flex space-x-2">
{colors.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-full ${getBadgeColor(c)} hover:ring-2 hover:ring-opacity-50 focus:outline-none focus:ring-2 focus:ring-opacity-50`}
onClick={() => handleColorSelect(c)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
{isEditable && (
<button
onClick={() => setShowPredefinedCallouts(!showPredefinedCallouts)}
className="text-neutral-300 hover:text-neutral-400 transition-colors"
>
<ChevronRight size={16} />
</button>
)}
{isEditable && showPredefinedCallouts && (
<div className='flex flex-wrap gap-2 absolute mt-8 bg-white/90 backdrop-blur-md p-2 rounded-lg nice-shadow'>
{predefinedBadges.map((badge, index) => (
<button
key={index}
onClick={() => handlePredefinedBadgeSelect(badge)}
className={`flex text-xs items-center px-3 py-1 rounded-xl space-x-2 ${getBadgeColor(badge.color)} text-gray-600 font-bold light-shadow hover:opacity-80 transition-all duration-100 ease-linear`}
>
<span className='text-xs'>{badge.emoji}</span>
<span className="content capitalize">{badge.content}</span>
</button>
))}
</div>
)}
</div>
{isEditable && showEmojiPicker && (
<div ref={pickerRef}>
<Picker
searchPosition="top"
theme="light"
previewPosition="none"
maxFrequentRows={0}
autoFocus={false}
onEmojiSelect={handleEmojiSelect}
/>
</div>
)}
</NodeViewWrapper>
)
}
export default BadgesExtension;

View file

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

View file

@ -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<HTMLDivElement>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const colorPickerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<NodeViewWrapper className={`block-button ${getAlignmentClass()}`}>
<div className='inline-block'>
<button
onClick={isEditable ? undefined : () => window.open(link, '_blank')}
className={twMerge(
'flex items-center space-x-2 py-2 px-4 rounded-xl text-white transition-colors',
getButtonColor(color),
isEditable && 'pointer-events-none',
!link && 'opacity-60'
)}
>
<span>{emoji}</span>
<NodeViewContent className="content" />
<ArrowRight size={14} />
</button>
{isEditable && (
<div className="flex mt-2 space-x-2">
<button onClick={() => setShowEmojiPicker(!showEmojiPicker)} className="p-1 bg-gray-200 rounded-md">
<ChevronDown size={14} />
</button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className="p-1 bg-gray-200 rounded-md">
<Link size={14} />
</button>
<button onClick={() => handleAlignmentChange('left')} className="p-1 bg-gray-200 rounded-md">
<AlignLeft size={14} />
</button>
<button onClick={() => handleAlignmentChange('center')} className="p-1 bg-gray-200 rounded-md">
<AlignCenter size={14} />
</button>
<button onClick={() => handleAlignmentChange('right')} className="p-1 bg-gray-200 rounded-md">
<AlignRight size={14} />
</button>
<button onClick={() => setShowColorPicker(!showColorPicker)} className="p-1 bg-gray-200 rounded-md">
<Palette size={14} />
</button>
</div>
)}
</div>
{isEditable && showEmojiPicker && (
<div ref={pickerRef}>
<Picker onEmojiSelect={handleEmojiSelect} />
</div>
)}
{isEditable && showLinkInput && (
<input
ref={linkInputRef}
type="text"
value={link}
onChange={handleLinkChange}
placeholder="Enter link URL"
className="mt-2 p-2 w-full border rounded-md"
/>
)}
{isEditable && showColorPicker && (
<div ref={colorPickerRef} className="absolute mt-2 p-2 bg-white rounded-md nice-shadow">
<div className="flex flex-wrap gap-2">
{colors.map((c) => (
<button
key={c}
className={`w-6 h-6 rounded-full ${getButtonColor(c)} hover:ring-2 hover:ring-opacity-50 focus:outline-none focus:ring-2 focus:ring-opacity-50`}
onClick={() => handleColorSelect(c)}
/>
))}
</div>
</div>
)}
</NodeViewWrapper>
)
}
export default ButtonsExtension

View file

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

View file

@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
const newCode = event.target.value;
setEmbedCode(newCode);
props.updateAttributes({
embedCode: newCode,
embedType: 'code',
});
};
const handleResizeStart = (event: React.MouseEvent<HTMLDivElement>, 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 (
<NodeViewWrapper className="embed-block">
<div
ref={resizeRef}
className={`relative bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center ${alignment === 'center' ? 'mx-auto' : ''}`}
style={{ height: `${embedHeight}px`, width: embedWidth, minWidth: '400px' }}
>
{embedType === 'url' && embedUrl ? (
<iframe
src={embedUrl}
className="w-full h-full"
frameBorder="0"
allowFullScreen
/>
) : embedType === 'code' && sanitizedEmbedCode ? (
<div dangerouslySetInnerHTML={{ __html: sanitizedEmbedCode }} className="w-full h-full" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-6">
<p className="text-gray-500 mb-4 font-medium tracking-tighter text-lg">Add an embed from :</p>
<div className="flex flex-wrap gap-5 justify-center">
{supportedProducts.map((product) => (
<button
key={product.name}
className="flex flex-col items-center group transition-transform hover:scale-110"
onClick={() => handleProductClick(product.guide)}
>
<div className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow" style={{ backgroundColor: product.color }}>
<product.icon size={24} color="#FFFFFF" />
</div>
<span className="text-xs mt-2 text-gray-700 group-hover:text-gray-900 font-medium">{product.name}</span>
</button>
))}
</div>
</div>
)}
<div className="absolute top-2 left-2 p-1 bg-white bg-opacity-70 rounded-md">
<Cuboid size={16} className="text-gray-600" />
</div>
{isEditable && (
<>
<div className="absolute bottom-2 left-2 flex gap-2">
<button
onClick={() => handleEmbedTypeChange('url')}
className={`p-2 rounded-md transition-colors ${embedType === 'url' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
>
<LinkIcon size={16} />
</button>
<button
onClick={() => handleEmbedTypeChange('code')}
className={`p-2 rounded-md transition-colors ${embedType === 'code' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
>
<Code size={16} />
</button>
{embedType === 'url' ? (
<input
type="text"
value={embedUrl}
onChange={handleUrlChange}
className="p-2 bg-white bg-opacity-70 rounded-md w-64"
placeholder="Enter embed URL"
/>
) : (
<textarea
value={embedCode}
onChange={handleCodeChange}
className="p-2 bg-white bg-opacity-70 rounded-md w-64 h-20"
placeholder="Enter embed code"
/>
)}
</div>
<button
onClick={handleCenterBlock}
className="absolute bottom-2 right-2 p-2 bg-white bg-opacity-70 rounded-md hover:bg-opacity-100 transition-opacity"
>
<AlignCenter size={16} className="text-gray-600" />
</button>
<div
className="absolute right-0 top-0 bottom-0 w-4 cursor-ew-resize flex items-center justify-center bg-white bg-opacity-70 hover:bg-opacity-100 transition-opacity"
onMouseDown={(e) => handleResizeStart(e, 'horizontal')}
>
<GripVertical size={16} className="text-gray-600" />
</div>
<div
className="absolute left-0 right-0 bottom-0 h-4 cursor-ns-resize flex items-center justify-center bg-white bg-opacity-70 hover:bg-opacity-100 transition-opacity"
onMouseDown={(e) => handleResizeStart(e, 'vertical')}
>
<GripHorizontal size={16} className="text-gray-600" />
</div>
</>
)}
</div>
</NodeViewWrapper>
)
}
export default EmbedObjectsComponent

View file

@ -13,12 +13,17 @@ import {
AlertTriangle,
BadgeHelp,
Code,
Cuboid,
FileText,
ImagePlus,
Lightbulb,
MousePointerClick,
Sigma,
Tag,
Tags,
Video,
Youtube,
} from 'lucide-react'
import { SiYoutube } from '@icons-pack/react-simple-icons'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
export const ToolbarButtons = ({ editor, props }: any) => {
@ -139,7 +144,7 @@ export const ToolbarButtons = ({ editor, props }: any) => {
</ToolTip>
<ToolTip content={'YouTube video'}>
<ToolBtn onClick={() => addYoutubeVideo()}>
<Youtube size={15} />
<SiYoutube size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'Math Equation (LaTeX)'}>
@ -195,6 +200,43 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<Code size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'External Object (Embed)'}>
<ToolBtn
onClick={() => editor.chain().focus().insertContent({ type: 'blockEmbed' }).run()}
>
<Cuboid size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'Badges'}>
<ToolBtn
onClick={() => editor.chain().focus().insertContent({
type: 'badge',
content: [
{
type: 'text',
text: 'This is a Badge'
}
]
}).run()}
>
<Tags size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'Button'}>
<ToolBtn
onClick={() => editor.chain().focus().insertContent({
type: 'button',
content: [
{
type: 'text',
text: 'Click me'
}
]
}).run()}
>
<MousePointerClick size={15} />
</ToolBtn>
</ToolTip>
</ToolButtonsWrapper>
)
}

View file

@ -11,7 +11,9 @@
"lint:fix": "eslint --fix ."
},
"dependencies": {
"@hocuspocus/provider": "^2.13.6",
"@emoji-mart/react": "^1.1.1",
"@hocuspocus/provider": "^2.13.7",
"@icons-pack/react-simple-icons": "^10.0.0",
"@radix-ui/colors": "^0.1.9",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.2",
@ -20,8 +22,8 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@sentry/nextjs": "^8.33.1",
"@sentry/utils": "^8.33.1",
"@sentry/nextjs": "^8.34.0",
"@sentry/utils": "^8.34.0",
"@stitches/react": "^1.2.8",
"@tiptap/core": "^2.8.0",
"@tiptap/extension-code-block-lowlight": "^2.8.0",
@ -32,11 +34,13 @@
"@tiptap/pm": "^2.8.0",
"@tiptap/react": "^2.8.0",
"@tiptap/starter-kit": "^2.8.0",
"@types/dompurify": "^3.0.5",
"@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dompurify": "^3.1.7",
"formik": "^2.4.6",
"framer-motion": "^10.18.0",
"get-youtube-id": "^1.0.1",
@ -44,7 +48,7 @@
"katex": "^0.16.11",
"lowlight": "^3.1.0",
"lucide-react": "^0.424.0",
"next": "14.2.7",
"next": "14.2.15",
"next-auth": "^4.24.8",
"nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3",
@ -64,6 +68,7 @@
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"unsplash-js": "^7.0.19",
"uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.12",
@ -81,7 +86,7 @@
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.14",
"eslint-config-next": "^14.2.15",
"eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",

682
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff