mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #320 from learnhouse/feat/editor-and-misc-updates
Editor & quality of life improvements
This commit is contained in:
commit
eedc5ab329
19 changed files with 1397 additions and 337 deletions
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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"
|
||||
|
|
@ -85,18 +93,31 @@ function ThumbnailUpdate() {
|
|||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThumbnailUpdate
|
||||
export default ThumbnailUpdate
|
||||
|
|
@ -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;
|
||||
|
|
@ -175,7 +175,7 @@ function UserEditGeneral() {
|
|||
}
|
||||
>
|
||||
<UploadCloud size={16} className="mr-2" />
|
||||
<span>Change Thumbnail</span>
|
||||
<span>Change Avatar</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
},
|
||||
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
682
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue