mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #453 from learnhouse/feat/learnings-new-input-ui
Learnings new input
This commit is contained in:
commit
34da409f30
5 changed files with 479 additions and 21 deletions
|
|
@ -29,9 +29,31 @@ const CourseClient = (props: any) => {
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
|
|
||||||
function getLearningTags() {
|
function getLearningTags() {
|
||||||
// create array of learnings from a string object (comma separated)
|
if (!course?.learnings) {
|
||||||
let learnings = course?.learnings ? course?.learnings.split('|') : []
|
setLearnings([])
|
||||||
setLearnings(learnings)
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -90,22 +112,44 @@ const CourseClient = (props: any) => {
|
||||||
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
|
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{learnings.length > 0 && learnings[0] !== 'null' && (
|
{learnings.length > 0 && learnings[0]?.text !== 'null' && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="py-3 text-2xl font-bold">
|
<h2 className="py-3 text-2xl font-bold">
|
||||||
What you will learn
|
What you will learn
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
|
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
|
||||||
{learnings.map((learning: any) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={learning}
|
key={learningId}
|
||||||
className="flex space-x-2 items-center font-semibold text-gray-500"
|
className="flex space-x-2 items-center font-semibold text-gray-500"
|
||||||
>
|
>
|
||||||
<div className="px-2 py-2 rounded-full">
|
<div className="px-2 py-2 rounded-full">
|
||||||
<Check className="text-gray-400" size={15} />
|
{learningEmoji ? (
|
||||||
|
<span>{learningEmoji}</span>
|
||||||
|
) : (
|
||||||
|
<Check className="text-gray-400" size={15} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p>{learning}</p>
|
<p>{learningText}</p>
|
||||||
|
{learning.link && (
|
||||||
|
<a
|
||||||
|
href={learning.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Link to {learningText}</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import React, { useEffect, useState } from 'react';
|
||||||
import ThumbnailUpdate from './ThumbnailUpdate';
|
import ThumbnailUpdate from './ThumbnailUpdate';
|
||||||
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
|
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
|
||||||
import FormTagInput from '@components/Objects/StyledElements/Form/TagInput';
|
import FormTagInput from '@components/Objects/StyledElements/Form/TagInput';
|
||||||
|
import LearningItemsList from './LearningItemsList';
|
||||||
|
|
||||||
type EditCourseStructureProps = {
|
type EditCourseStructureProps = {
|
||||||
orgslug: string
|
orgslug: string
|
||||||
|
|
@ -34,6 +35,23 @@ const validate = (values: any) => {
|
||||||
|
|
||||||
if (!values.learnings) {
|
if (!values.learnings) {
|
||||||
errors.learnings = 'Required';
|
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;
|
return errors;
|
||||||
|
|
@ -45,12 +63,51 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
|
||||||
const dispatchCourse = useCourseDispatch() as any;
|
const dispatchCourse = useCourseDispatch() as any;
|
||||||
const { isLoading, courseStructure } = course 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({
|
const formik = useFormik({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: courseStructure?.name || '',
|
name: courseStructure?.name || '',
|
||||||
description: courseStructure?.description || '',
|
description: courseStructure?.description || '',
|
||||||
about: courseStructure?.about || '',
|
about: courseStructure?.about || '',
|
||||||
learnings: courseStructure?.learnings || '',
|
learnings: initializeLearnings(courseStructure?.learnings || ''),
|
||||||
tags: courseStructure?.tags || '',
|
tags: courseStructure?.tags || '',
|
||||||
public: courseStructure?.public || false,
|
public: courseStructure?.public || false,
|
||||||
},
|
},
|
||||||
|
|
@ -139,11 +196,11 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
|
||||||
<FormField name="learnings">
|
<FormField name="learnings">
|
||||||
<FormLabelAndMessage label="Learnings" message={formik.errors.learnings} />
|
<FormLabelAndMessage label="Learnings" message={formik.errors.learnings} />
|
||||||
<Form.Control asChild>
|
<Form.Control asChild>
|
||||||
<FormTagInput
|
<LearningItemsList
|
||||||
placeholder="Enter to add..."
|
value={formik.values.learnings}
|
||||||
onChange={(value) => formik.setFieldValue('learnings', value)}
|
onChange={(value) => formik.setFieldValue('learnings', value)}
|
||||||
value={formik.values.learnings}
|
error={formik.errors.learnings}
|
||||||
/>
|
/>
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<LearningItem[]>([]);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState<string | null>(null);
|
||||||
|
const [showLinkInput, setShowLinkInput] = useState<string | null>(null);
|
||||||
|
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const linkInputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const linkInputFieldRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-center py-3 text-gray-500 bg-gray-50/50 rounded-lg text-sm">
|
||||||
|
No learning items added yet. Click the button below to add one.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className={`space-y-2 ${isScrollable ? 'max-h-[350px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent' : ''}`}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} id={`learning-item-${item.id}`} className="group relative">
|
||||||
|
<div className="flex items-center gap-2 py-2 px-3 bg-gray-50/70 hover:bg-gray-50 border border-gray-100 rounded-lg transition-colors">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEmojiPicker(showEmojiPicker === item.id ? null : item.id);
|
||||||
|
setShowLinkInput(null);
|
||||||
|
}}
|
||||||
|
className="text-lg flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span>{item.emoji}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
ref={setInputRef(item.id)}
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div className="text-xs text-blue-500 flex items-center gap-1 bg-blue-50 px-2 py-0.5 rounded">
|
||||||
|
<LinkIcon size={12} />
|
||||||
|
<span className="truncate max-w-[100px]">{item.link}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLinkInput(showLinkInput === item.id ? null : item.id);
|
||||||
|
setShowEmojiPicker(null);
|
||||||
|
setFocusedItemId(item.id);
|
||||||
|
// Focus the link input after render
|
||||||
|
setTimeout(() => {
|
||||||
|
if (linkInputFieldRefs.current[item.id]) {
|
||||||
|
linkInputFieldRefs.current[item.id]?.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-blue-500 transition-colors"
|
||||||
|
title={item.link ? "Edit link" : "Add link"}
|
||||||
|
>
|
||||||
|
<LinkIcon size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
className="text-gray-300 hover:text-gray-500 transition-colors"
|
||||||
|
aria-label="Remove item"
|
||||||
|
title="Remove item"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmojiPicker === item.id && (
|
||||||
|
<div ref={pickerRef} className="absolute z-10 mt-1 left-0">
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={(emoji: any) => handleEmojiSelect(item.id, emoji)}
|
||||||
|
theme="light"
|
||||||
|
previewPosition="none"
|
||||||
|
searchPosition="top"
|
||||||
|
maxFrequentRows={0}
|
||||||
|
autoFocus={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLinkInput === item.id && (
|
||||||
|
<div ref={linkInputRef} className="mt-1 p-2 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<Input
|
||||||
|
ref={setLinkInputRef(item.id)}
|
||||||
|
value={items.find(i => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors mt-2"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="text-blue-500" />
|
||||||
|
<span>Add learning item</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearningItemsList;
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"lint:fix": "eslint --fix ."
|
"lint:fix": "eslint --fix ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@icons-pack/react-simple-icons": "^10.1.0",
|
"@icons-pack/react-simple-icons": "^10.1.0",
|
||||||
"@radix-ui/colors": "^0.1.9",
|
"@radix-ui/colors": "^0.1.9",
|
||||||
|
|
|
||||||
22
apps/web/pnpm-lock.yaml
generated
22
apps/web/pnpm-lock.yaml
generated
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@emoji-mart/data':
|
||||||
|
specifier: ^1.2.1
|
||||||
|
version: 1.2.1
|
||||||
'@emoji-mart/react':
|
'@emoji-mart/react':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(emoji-mart@5.6.0)(react@18.3.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)
|
version: 14.2.16(eslint@8.57.1)(typescript@5.4.4)
|
||||||
eslint-plugin-unused-imports:
|
eslint-plugin-unused-imports:
|
||||||
specifier: ^3.2.0
|
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:
|
postcss:
|
||||||
specifier: ^8.4.47
|
specifier: ^8.4.47
|
||||||
version: 8.4.47
|
version: 8.4.47
|
||||||
|
|
@ -348,6 +351,9 @@ packages:
|
||||||
'@emnapi/runtime@1.3.1':
|
'@emnapi/runtime@1.3.1':
|
||||||
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
|
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':
|
'@emoji-mart/react@1.1.1':
|
||||||
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
|
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -4461,8 +4467,8 @@ packages:
|
||||||
tailwind-merge@2.5.4:
|
tailwind-merge@2.5.4:
|
||||||
resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==}
|
resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==}
|
||||||
|
|
||||||
tailwind-merge@3.0.1:
|
tailwind-merge@3.0.2:
|
||||||
resolution: {integrity: sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==}
|
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||||
|
|
||||||
tailwind-scrollbar@3.1.0:
|
tailwind-scrollbar@3.1.0:
|
||||||
resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==}
|
resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==}
|
||||||
|
|
@ -4939,6 +4945,8 @@ snapshots:
|
||||||
tslib: 2.8.0
|
tslib: 2.8.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emoji-mart/data@1.2.1': {}
|
||||||
|
|
||||||
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)':
|
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-mart: 5.6.0
|
emoji-mart: 5.6.0
|
||||||
|
|
@ -7486,7 +7494,7 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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)
|
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)
|
tsup: 6.7.0(postcss@8.4.47)(typescript@5.4.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/core'
|
- '@swc/core'
|
||||||
|
|
@ -7772,12 +7780,10 @@ snapshots:
|
||||||
string.prototype.matchall: 4.0.11
|
string.prototype.matchall: 4.0.11
|
||||||
string.prototype.repeat: 1.0.0
|
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:
|
dependencies:
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-rule-composer: 0.3.0
|
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: {}
|
eslint-rule-composer@0.3.0: {}
|
||||||
|
|
||||||
|
|
@ -9366,7 +9372,7 @@ snapshots:
|
||||||
|
|
||||||
tailwind-merge@2.5.4: {}
|
tailwind-merge@2.5.4: {}
|
||||||
|
|
||||||
tailwind-merge@3.0.1: {}
|
tailwind-merge@3.0.2: {}
|
||||||
|
|
||||||
tailwind-scrollbar@3.1.0(tailwindcss@3.4.14):
|
tailwind-scrollbar@3.1.0(tailwindcss@3.4.14):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue