From 05efdcb6428ccc13175f276e4c6e65b1b32e3113 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 27 Feb 2025 09:59:39 +0100 Subject: [PATCH] feat: Enhance Course Learning Items with Emoji and Flexible Parsing --- .../(withmenu)/course/[courseuuid]/course.tsx | 58 ++- .../EditCourseGeneral/EditCourseGeneral.tsx | 69 +++- .../EditCourseGeneral/LearningItemsList.tsx | 350 ++++++++++++++++++ apps/web/package.json | 1 + apps/web/pnpm-lock.yaml | 22 +- 5 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/LearningItemsList.tsx diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index 6adb5836..bdb133f6 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -29,9 +29,31 @@ const CourseClient = (props: any) => { const isMobile = useMediaQuery('(max-width: 768px)') function getLearningTags() { - // create array of learnings from a string object (comma separated) - let learnings = course?.learnings ? course?.learnings.split('|') : [] - setLearnings(learnings) + if (!course?.learnings) { + setLearnings([]) + return + } + + try { + // Try to parse as JSON (new format) + const parsedLearnings = JSON.parse(course.learnings) + if (Array.isArray(parsedLearnings)) { + // New format: array of learning items with text and emoji + setLearnings(parsedLearnings) + return + } + } catch (e) { + // Not valid JSON, continue to legacy format handling + } + + // Legacy format: comma-separated string (changed from pipe-separated) + const learningItems = course.learnings.split(',').map((text: string) => ({ + id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(), + text: text.trim(), // Trim whitespace that might be present after commas + emoji: '📝' // Default emoji for legacy items + })) + + setLearnings(learningItems) } useEffect(() => { @@ -90,22 +112,44 @@ const CourseClient = (props: any) => {

{course.about}

- {learnings.length > 0 && learnings[0] !== 'null' && ( + {learnings.length > 0 && learnings[0]?.text !== 'null' && (

What you will learn

{learnings.map((learning: any) => { + // Handle both new format (object with text and emoji) and legacy format (string) + const learningText = typeof learning === 'string' ? learning : learning.text + const learningEmoji = typeof learning === 'string' ? null : learning.emoji + const learningId = typeof learning === 'string' ? learning : learning.id || learning.text + + if (!learningText) return null + return (
- + {learningEmoji ? ( + {learningEmoji} + ) : ( + + )}
-

{learning}

+

{learningText}

+ {learning.link && ( + + Link to {learningText} + + + )}
) })} diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx index 1c250168..87a93686 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useState } from 'react'; import ThumbnailUpdate from './ThumbnailUpdate'; import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'; import FormTagInput from '@components/Objects/StyledElements/Form/TagInput'; +import LearningItemsList from './LearningItemsList'; type EditCourseStructureProps = { orgslug: string @@ -34,6 +35,23 @@ const validate = (values: any) => { if (!values.learnings) { errors.learnings = 'Required'; + } else { + try { + const learningItems = JSON.parse(values.learnings); + if (!Array.isArray(learningItems)) { + errors.learnings = 'Invalid format'; + } else if (learningItems.length === 0) { + errors.learnings = 'At least one learning item is required'; + } else { + // Check if any item has empty text + const hasEmptyText = learningItems.some(item => !item.text || item.text.trim() === ''); + if (hasEmptyText) { + errors.learnings = 'All learning items must have text'; + } + } + } catch (e) { + errors.learnings = 'Invalid JSON format'; + } } return errors; @@ -45,12 +63,51 @@ function EditCourseGeneral(props: EditCourseStructureProps) { const dispatchCourse = useCourseDispatch() as any; const { isLoading, courseStructure } = course as any; + // Initialize learnings as a JSON array if it's not already + const initializeLearnings = (learnings: any) => { + if (!learnings) { + return JSON.stringify([{ id: Date.now().toString(), text: '', emoji: '📝' }]); + } + + try { + // Check if it's already a valid JSON array + const parsed = JSON.parse(learnings); + if (Array.isArray(parsed)) { + return learnings; + } + + // If it's a string but not a JSON array, convert it to a learning item + if (typeof learnings === 'string') { + return JSON.stringify([{ + id: Date.now().toString(), + text: learnings, + emoji: '📝' + }]); + } + + // Default empty array + return JSON.stringify([{ id: Date.now().toString(), text: '', emoji: '📝' }]); + } catch (e) { + // If it's not valid JSON, convert the string to a learning item + if (typeof learnings === 'string') { + return JSON.stringify([{ + id: Date.now().toString(), + text: learnings, + emoji: '📝' + }]); + } + + // Default empty array + return JSON.stringify([{ id: Date.now().toString(), text: '', emoji: '📝' }]); + } + }; + const formik = useFormik({ initialValues: { name: courseStructure?.name || '', description: courseStructure?.description || '', about: courseStructure?.about || '', - learnings: courseStructure?.learnings || '', + learnings: initializeLearnings(courseStructure?.learnings || ''), tags: courseStructure?.tags || '', public: courseStructure?.public || false, }, @@ -139,11 +196,11 @@ function EditCourseGeneral(props: EditCourseStructureProps) { - formik.setFieldValue('learnings', value)} - value={formik.values.learnings} - /> + formik.setFieldValue('learnings', value)} + error={formik.errors.learnings} + /> diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/LearningItemsList.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/LearningItemsList.tsx new file mode 100644 index 00000000..7ecb2ecd --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/LearningItemsList.tsx @@ -0,0 +1,350 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Plus, X, Link as LinkIcon, Smile } from 'lucide-react'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; +import { Input } from '@components/ui/input'; + +interface LearningItem { + id: string; + text: string; + emoji: string; + link?: string; +} + +interface LearningItemsListProps { + value: string; + onChange: (value: string) => void; + error?: string; +} + +const LearningItemsList = ({ value, onChange, error }: LearningItemsListProps) => { + const [items, setItems] = useState([]); + const [showEmojiPicker, setShowEmojiPicker] = useState(null); + const [showLinkInput, setShowLinkInput] = useState(null); + const [focusedItemId, setFocusedItemId] = useState(null); + const pickerRef = useRef(null); + const linkInputRef = useRef(null); + const initializedRef = useRef(false); + const inputRefs = useRef>({}); + const linkInputFieldRefs = useRef>({}); + const scrollContainerRef = useRef(null); + + // Add a new empty item + const addItem = () => { + const newItem: LearningItem = { + id: Date.now().toString(), + text: '', + emoji: '📝', + }; + const newItems = [...items, newItem]; + setItems(newItems); + onChange(JSON.stringify(newItems)); + + // Focus the newly added item after render + setTimeout(() => { + if (inputRefs.current[newItem.id]) { + inputRefs.current[newItem.id]?.focus(); + setFocusedItemId(newItem.id); + } + + // Scroll to the bottom when a new item is added + if (scrollContainerRef.current && newItems.length > 5) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + } + }, 0); + }; + + // Parse the JSON string to items array when the component mounts or value changes + useEffect(() => { + try { + if (value) { + const parsedItems = JSON.parse(value); + if (Array.isArray(parsedItems)) { + setItems(parsedItems); + initializedRef.current = true; + } else if (!initializedRef.current) { + // Initialize with one empty item if no valid array and not already initialized + const newItem: LearningItem = { + id: Date.now().toString(), + text: '', + emoji: '📝', + }; + setItems([newItem]); + onChange(JSON.stringify([newItem])); + initializedRef.current = true; + } + } else if (!initializedRef.current) { + // Initialize with one empty item if no value and not already initialized + const newItem: LearningItem = { + id: Date.now().toString(), + text: '', + emoji: '📝', + }; + setItems([newItem]); + onChange(JSON.stringify([newItem])); + initializedRef.current = true; + } + } catch (e) { + console.error('Error parsing learning items:', e); + // Initialize with one empty item on error if not already initialized + if (!initializedRef.current) { + const newItem: LearningItem = { + id: Date.now().toString(), + text: '', + emoji: '📝', + }; + setItems([newItem]); + onChange(JSON.stringify([newItem])); + initializedRef.current = true; + } + } + }, [value]); + + // Restore focus after re-render if an item was focused + useEffect(() => { + if (focusedItemId) { + if (showLinkInput === focusedItemId) { + // Focus the link input if it's open for the focused item + if (linkInputFieldRefs.current[focusedItemId]) { + linkInputFieldRefs.current[focusedItemId]?.focus(); + } + } else { + // Focus the text input + if (inputRefs.current[focusedItemId]) { + inputRefs.current[focusedItemId]?.focus(); + } + } + + // Scroll the focused item into view if needed + if (items.length > 5 && scrollContainerRef.current) { + const focusedElement = document.getElementById(`learning-item-${focusedItemId}`); + if (focusedElement) { + const containerRect = scrollContainerRef.current.getBoundingClientRect(); + const elementRect = focusedElement.getBoundingClientRect(); + + // Check if the element is outside the visible area + if (elementRect.top < containerRect.top || elementRect.bottom > containerRect.bottom) { + focusedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + } + } + }, [items, focusedItemId, showLinkInput]); + + // Handle clicks outside of emoji picker and link input + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + setShowEmojiPicker(null); + } + if (linkInputRef.current && !linkInputRef.current.contains(event.target as Node)) { + setShowLinkInput(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Update the parent component with the new JSON string when items change + const updateItems = (newItems: LearningItem[]) => { + setItems(newItems); + onChange(JSON.stringify(newItems)); + }; + + // Remove an item + const removeItem = (id: string) => { + if (focusedItemId === id) { + setFocusedItemId(null); + } + updateItems(items.filter(item => item.id !== id)); + }; + + // Update item text + const updateItemText = (id: string, text: string) => { + updateItems( + items.map(item => (item.id === id ? { ...item, text } : item)) + ); + }; + + // Update item emoji + const updateItemEmoji = (id: string, emoji: string) => { + updateItems( + items.map(item => (item.id === id ? { ...item, emoji } : item)) + ); + setShowEmojiPicker(null); + + // Restore focus to the text input after emoji selection + setTimeout(() => { + if (inputRefs.current[id]) { + inputRefs.current[id]?.focus(); + setFocusedItemId(id); + } + }, 0); + }; + + // Update item link + const updateItemLink = (id: string, link: string) => { + updateItems( + items.map(item => (item.id === id ? { ...item, link } : item)) + ); + }; + + // Handle emoji selection + const handleEmojiSelect = (id: string, emojiData: any) => { + updateItemEmoji(id, emojiData.native); + }; + + // Handle focus on input + const handleInputFocus = (id: string) => { + setFocusedItemId(id); + }; + + // Handle blur on input + const handleInputBlur = () => { + // Don't clear focusedItemId immediately as it might be needed for refocusing + // We'll use a small delay to allow other focus events to occur first + setTimeout(() => { + // Only clear if we're not focusing another input in this component + if (!document.activeElement || + !document.activeElement.classList.contains('learning-item-input')) { + setFocusedItemId(null); + } + }, 100); + }; + + // Ref callback for text inputs + const setInputRef = (id: string) => (el: HTMLInputElement | null) => { + inputRefs.current[id] = el; + }; + + // Ref callback for link inputs + const setLinkInputRef = (id: string) => (el: HTMLInputElement | null) => { + linkInputFieldRefs.current[id] = el; + }; + + // Determine if we need to make the list scrollable + const isScrollable = items.length > 5; + + return ( +
+ {items.length === 0 && ( +
+ No learning items added yet. Click the button below to add one. +
+ )} + +
+ {items.map((item) => ( +
+
+ + + updateItemText(item.id, e.target.value)} + onFocus={() => handleInputFocus(item.id)} + onBlur={handleInputBlur} + placeholder="Enter learning item..." + className="flex-grow border-0 bg-transparent focus-visible:ring-0 px-0 h-8 text-sm learning-item-input" + /> + + {item.link && ( +
+ + {item.link} +
+ )} + +
+ + + +
+
+ + {showEmojiPicker === item.id && ( +
+ handleEmojiSelect(item.id, emoji)} + theme="light" + previewPosition="none" + searchPosition="top" + maxFrequentRows={0} + autoFocus={false} + /> +
+ )} + + {showLinkInput === item.id && ( +
+ i.id === item.id)?.link || ''} + onChange={(e) => updateItemLink(item.id, e.target.value)} + onFocus={() => handleInputFocus(item.id)} + onBlur={handleInputBlur} + placeholder="Enter URL..." + className="w-full text-sm learning-item-input" + autoFocus + /> +
+ )} +
+ ))} +
+ + +
+ ); +}; + +export default LearningItemsList; \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 0aca8001..ce8a9b70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { + "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@icons-pack/react-simple-icons": "^10.1.0", "@radix-ui/colors": "^0.1.9", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 7ac160c1..e7f0a105 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 '@emoji-mart/react': specifier: ^1.1.1 version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1) @@ -257,7 +260,7 @@ importers: version: 14.2.16(eslint@8.57.1)(typescript@5.4.4) eslint-plugin-unused-imports: specifier: ^3.2.0 - version: 3.2.0(@typescript-eslint/eslint-plugin@8.12.2(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1) + version: 3.2.0(eslint@8.57.1) postcss: specifier: ^8.4.47 version: 8.4.47 @@ -348,6 +351,9 @@ packages: '@emnapi/runtime@1.3.1': resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + '@emoji-mart/react@1.1.1': resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} peerDependencies: @@ -4461,8 +4467,8 @@ packages: tailwind-merge@2.5.4: resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==} - tailwind-merge@3.0.1: - resolution: {integrity: sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==} + tailwind-merge@3.0.2: + resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==} tailwind-scrollbar@3.1.0: resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==} @@ -4939,6 +4945,8 @@ snapshots: tslib: 2.8.0 optional: true + '@emoji-mart/data@1.2.1': {} + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)': dependencies: emoji-mart: 5.6.0 @@ -7486,7 +7494,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-easy-sort: 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - tailwind-merge: 3.0.1 + tailwind-merge: 3.0.2 tsup: 6.7.0(postcss@8.4.47)(typescript@5.4.4) transitivePeerDependencies: - '@swc/core' @@ -7772,12 +7780,10 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 - eslint-plugin-unused-imports@3.2.0(@typescript-eslint/eslint-plugin@8.12.2(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1): + eslint-plugin-unused-imports@3.2.0(eslint@8.57.1): dependencies: eslint: 8.57.1 eslint-rule-composer: 0.3.0 - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.12.2(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1)(typescript@5.4.4) eslint-rule-composer@0.3.0: {} @@ -9366,7 +9372,7 @@ snapshots: tailwind-merge@2.5.4: {} - tailwind-merge@3.0.1: {} + tailwind-merge@3.0.2: {} tailwind-scrollbar@3.1.0(tailwindcss@3.4.14): dependencies: