From 98dfad76aad70711a8113f6c1fdabfccf10509ca Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 21 Sep 2025 22:25:51 +0200 Subject: [PATCH] feat: add Flipcard component support with configuration and styling for enhanced interactivity --- .../Activities/DynamicCanva/DynamicCanva.tsx | 5 + apps/web/components/Objects/Editor/Editor.tsx | 5 + .../Editor/Extensions/Flipcard/Flipcard.ts | 46 +++ .../Extensions/Flipcard/FlipcardExtension.tsx | 358 ++++++++++++++++++ .../Objects/Editor/Toolbar/ToolbarButtons.tsx | 19 + apps/web/styles/globals.css | 32 ++ 6 files changed, 465 insertions(+) create mode 100644 apps/web/components/Objects/Editor/Extensions/Flipcard/Flipcard.ts create mode 100644 apps/web/components/Objects/Editor/Extensions/Flipcard/FlipcardExtension.tsx diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index e74dad42..13c820cc 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -27,6 +27,7 @@ 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' +import Flipcard from '@components/Objects/Editor/Extensions/Flipcard/Flipcard' import Table from '@tiptap/extension-table' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' @@ -136,6 +137,10 @@ function Canva(props: Editor) { editable: true, activity: props.activity, }), + Flipcard.configure({ + editable: false, + activity: props.activity, + }), TableRow, TableHeader, TableCell, diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 3a6def37..b38cdb19 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -53,6 +53,7 @@ import { getUriWithOrg } from '@services/config/config' import EmbedObjects from './Extensions/EmbedObjects/EmbedObjects' import Badges from './Extensions/Badges/Badges' import Buttons from './Extensions/Buttons/Buttons' +import Flipcard from './Extensions/Flipcard/Flipcard' import { useMediaQuery } from 'usehooks-ts' import UserAvatar from '../UserAvatar' import UserBlock from './Extensions/Users/UserBlock' @@ -169,6 +170,10 @@ function Editor(props: Editor) { editable: true, activity: props.activity, }), + Flipcard.configure({ + editable: true, + activity: props.activity, + }), ], content: props.content, immediatelyRender: false, diff --git a/apps/web/components/Objects/Editor/Extensions/Flipcard/Flipcard.ts b/apps/web/components/Objects/Editor/Extensions/Flipcard/Flipcard.ts new file mode 100644 index 00000000..4562de38 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Flipcard/Flipcard.ts @@ -0,0 +1,46 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { mergeAttributes, Node } from "@tiptap/core"; +import FlipcardExtension from "./FlipcardExtension"; + +export default Node.create({ + name: "flipcard", + group: "block", + draggable: true, + content: "text*", + + addAttributes() { + return { + question: { + default: 'Click to reveal the answer', + }, + answer: { + default: 'This is the answer', + }, + color: { + default: 'blue', + }, + alignment: { + default: 'center', + }, + size: { + default: 'medium', + }, + }; + }, + + parseHTML() { + return [ + { + tag: "flipcard-block", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["flipcard-block", mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(FlipcardExtension); + }, +}); diff --git a/apps/web/components/Objects/Editor/Extensions/Flipcard/FlipcardExtension.tsx b/apps/web/components/Objects/Editor/Extensions/Flipcard/FlipcardExtension.tsx new file mode 100644 index 00000000..7e269792 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Flipcard/FlipcardExtension.tsx @@ -0,0 +1,358 @@ +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react' +import React, { useState, useRef, useEffect } from 'react' +import { RotateCw, Edit, AlignLeft, AlignCenter, AlignRight, Palette, Maximize2, Minimize2, Square } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' + +const FlipcardExtension: React.FC = (props: any) => { + const [isFlipped, setIsFlipped] = useState(false) + const [question, setQuestion] = useState(props.node.attrs.question) + const [answer, setAnswer] = useState(props.node.attrs.answer) + const [color, setColor] = useState(props.node.attrs.color || 'blue') + const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'center') + const [size, setSize] = useState(props.node.attrs.size || 'medium') + const [showColorPicker, setShowColorPicker] = useState(false) + const [isEditingQuestion, setIsEditingQuestion] = useState(false) + const [isEditingAnswer, setIsEditingAnswer] = useState(false) + const colorPickerRef = useRef(null) + const questionInputRef = useRef(null) + const answerInputRef = useRef(null) + const editorState = useEditorProvider() as any + const isEditable = editorState.isEditable + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (colorPickerRef.current && !colorPickerRef.current.contains(event.target as Node)) { + setShowColorPicker(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const handleFlip = () => { + // Allow flipping in both edit and view modes, but prevent when editing text + if (!isEditingQuestion && !isEditingAnswer) { + setIsFlipped(!isFlipped) + } + } + + const handleQuestionChange = (e: React.ChangeEvent) => { + setQuestion(e.target.value) + props.updateAttributes({ + question: e.target.value, + }) + } + + const handleAnswerChange = (e: React.ChangeEvent) => { + setAnswer(e.target.value) + props.updateAttributes({ + answer: e.target.value, + }) + } + + const handleAlignmentChange = (newAlignment: 'left' | 'center' | 'right') => { + setAlignment(newAlignment) + props.updateAttributes({ + alignment: newAlignment, + }) + } + + const handleColorSelect = (selectedColor: string) => { + setColor(selectedColor) + setShowColorPicker(false) + props.updateAttributes({ + color: selectedColor, + }) + } + + const handleSizeChange = (newSize: 'small' | 'medium' | 'large') => { + setSize(newSize) + props.updateAttributes({ + size: newSize, + }) + } + + const getAlignmentClass = () => { + switch (alignment) { + case 'left': return 'text-left justify-start'; + case 'center': return 'text-center justify-center'; + case 'right': return 'text-right justify-end'; + default: return 'text-center justify-center'; + } + } + + const getSizeClass = () => { + switch (size) { + case 'small': return 'w-64 h-36'; + case 'medium': return 'w-80 h-48'; + case 'large': return 'w-96 h-60'; + default: return 'w-80 h-48'; + } + } + + const getFontSizeClass = () => { + switch (size) { + case 'small': return 'text-sm'; + case 'medium': return 'text-lg'; + case 'large': return 'text-xl'; + default: return 'text-lg'; + } + } + + const getIconSizeClass = () => { + switch (size) { + case 'small': return 16; + case 'medium': return 20; + case 'large': return 24; + default: return 20; + } + } + + const getCardColor = (color: string, isBack: boolean = false) => { + const baseColors = { + sky: isBack ? 'bg-sky-600 border-sky-700' : 'bg-sky-500 border-sky-600', + green: isBack ? 'bg-green-600 border-green-700' : 'bg-green-500 border-green-600', + yellow: isBack ? 'bg-yellow-600 border-yellow-700' : 'bg-yellow-500 border-yellow-600', + red: isBack ? 'bg-red-600 border-red-700' : 'bg-red-500 border-red-600', + purple: isBack ? 'bg-purple-600 border-purple-700' : 'bg-purple-500 border-purple-600', + teal: isBack ? 'bg-teal-600 border-teal-700' : 'bg-teal-500 border-teal-600', + amber: isBack ? 'bg-amber-600 border-amber-700' : 'bg-amber-500 border-amber-600', + indigo: isBack ? 'bg-indigo-600 border-indigo-700' : 'bg-indigo-500 border-indigo-600', + neutral: isBack ? 'bg-neutral-700 border-neutral-800' : 'bg-neutral-600 border-neutral-700', + blue: isBack ? 'bg-blue-600 border-blue-700' : 'bg-blue-500 border-blue-600', + } + return baseColors[color as keyof typeof baseColors] || baseColors.blue + } + + const colors = ['sky', 'green', 'yellow', 'red', 'purple', 'teal', 'amber', 'indigo', 'neutral', 'blue'] + + const handleQuestionEdit = () => { + setIsEditingQuestion(true) + setTimeout(() => questionInputRef.current?.focus(), 0) + } + + const handleAnswerEdit = () => { + setIsEditingAnswer(true) + setTimeout(() => answerInputRef.current?.focus(), 0) + } + + const handleQuestionBlur = () => { + setIsEditingQuestion(false) + } + + const handleAnswerBlur = () => { + setIsEditingAnswer(false) + } + + return ( + +
+
+ {/* Front Side (Question) */} +
+
+ +
+
+ {isEditable && isEditingQuestion ? ( +