import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Task } from '../../entities/Task'; import { Tag } from '../../entities/Tag'; import { useToast } from '../Shared/ToastContext'; import { useTranslation } from 'react-i18next'; import { createInboxItemWithStore } from '../../utils/inboxService'; import { isAuthError } from '../../utils/authUtils'; import { createTag } from '../../utils/tagsService'; import { XMarkIcon, TagIcon } from '@heroicons/react/24/outline'; import { useStore } from '../../store/useStore'; import { Link } from 'react-router-dom'; // import UrlPreview from "../Shared/UrlPreview"; // import { UrlTitleResult } from "../../utils/urlService"; interface InboxModalProps { isOpen: boolean; onClose: () => void; onSave: (task: Task) => Promise; initialText?: string; editMode?: boolean; onEdit?: (text: string) => Promise; } const InboxModal: React.FC = ({ isOpen, onClose, onSave, initialText = '', editMode = false, onEdit, }) => { const { t } = useTranslation(); const [inputText, setInputText] = useState(initialText); const modalRef = useRef(null); const [isClosing, setIsClosing] = useState(false); const [isSaving, setIsSaving] = useState(false); const { showSuccessToast, showErrorToast } = useToast(); const nameInputRef = useRef(null); const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox'); const { tagsStore: { tags, setTags }, } = useStore(); const [showTagSuggestions, setShowTagSuggestions] = useState(false); const [filteredTags, setFilteredTags] = useState([]); const [cursorPosition, setCursorPosition] = useState(0); const [, setCurrentHashtagQuery] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0, }); // const [urlPreview, setUrlPreview] = useState(null); // Dispatch global modal events to hide floating + button // Helper function to parse hashtags from text const parseHashtags = (text: string): string[] => { const hashtagRegex = /#([a-zA-Z0-9_]+)/g; const matches = text.match(hashtagRegex); return matches ? matches.map((tag) => tag.substring(1)) : []; }; // Helper function to get current hashtag query at cursor position const getCurrentHashtagQuery = (text: string, position: number): string => { const beforeCursor = text.substring(0, position); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); return hashtagMatch ? hashtagMatch[1] : ''; }; // Helper function to render text with clickable hashtags // const renderTextWithHashtags = (text: string) => { // const parts = text.split(/(#[a-zA-Z0-9_]+)/g); // return parts.map((part, index) => { // if (part.startsWith('#')) { // const tagName = part.substring(1); // const tag = tags.find( // (t) => t.name.toLowerCase() === tagName.toLowerCase() // ); // if (tag) { // return ( // e.stopPropagation()} // > // {part} // // ); // } // } // return {part}; // }); // }; // Helper function to calculate dropdown position based on cursor const calculateDropdownPosition = ( input: HTMLInputElement, cursorPos: number ) => { // Create a temporary element to measure text width const temp = document.createElement('span'); temp.style.visibility = 'hidden'; temp.style.position = 'absolute'; temp.style.fontSize = getComputedStyle(input).fontSize; temp.style.fontFamily = getComputedStyle(input).fontFamily; temp.style.fontWeight = getComputedStyle(input).fontWeight; temp.textContent = inputText.substring(0, cursorPos); document.body.appendChild(temp); const textWidth = temp.getBoundingClientRect().width; document.body.removeChild(temp); // Get the # position for the current hashtag const beforeCursor = inputText.substring(0, cursorPos); const hashtagMatch = beforeCursor.match(/#[a-zA-Z0-9_]*$/); if (hashtagMatch) { const hashtagStart = beforeCursor.lastIndexOf('#'); // Create temp element for text up to hashtag start const tempToHashtag = document.createElement('span'); tempToHashtag.style.visibility = 'hidden'; tempToHashtag.style.position = 'absolute'; tempToHashtag.style.fontSize = getComputedStyle(input).fontSize; tempToHashtag.style.fontFamily = getComputedStyle(input).fontFamily; tempToHashtag.style.fontWeight = getComputedStyle(input).fontWeight; tempToHashtag.textContent = inputText.substring(0, hashtagStart); document.body.appendChild(tempToHashtag); const hashtagOffset = tempToHashtag.getBoundingClientRect().width; document.body.removeChild(tempToHashtag); return { left: hashtagOffset, top: input.offsetHeight, }; } return { left: textWidth, top: input.offsetHeight }; }; useEffect(() => { if (isOpen && nameInputRef.current) { nameInputRef.current.focus(); } }, [isOpen]); // Prevent body scroll when modal is open useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = 'unset'; } // Cleanup function to restore scroll when component unmounts return () => { document.body.style.overflow = 'unset'; }; }, [isOpen]); const handleChange = (e: React.ChangeEvent) => { const newText = e.target.value; const newCursorPosition = e.target.selectionStart || 0; setInputText(newText); setCursorPosition(newCursorPosition); // Check if user is typing a hashtag const hashtagQuery = getCurrentHashtagQuery(newText, newCursorPosition); setCurrentHashtagQuery(hashtagQuery); if (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) { // Filter tags based on current query const filtered = tags .filter((tag) => tag.name .toLowerCase() .startsWith(hashtagQuery.toLowerCase()) ) .slice(0, 5); // Limit to 5 suggestions // Calculate dropdown position const position = calculateDropdownPosition( e.target, newCursorPosition ); setDropdownPosition(position); setFilteredTags(filtered); setShowTagSuggestions(true); } else { setShowTagSuggestions(false); setFilteredTags([]); } }; // Handle tag suggestion selection const handleTagSelect = (tagName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); if (hashtagMatch) { const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + afterCursor; setInputText(newText); setShowTagSuggestions(false); setFilteredTags([]); // Focus back on input and set cursor position setTimeout(() => { if (nameInputRef.current) { nameInputRef.current.focus(); const newCursorPos = beforeCursor.replace( /#([a-zA-Z0-9_]*)$/, `#${tagName}` ).length; nameInputRef.current.setSelectionRange( newCursorPos, newCursorPos ); } }, 0); } }; // Create missing tags automatically const createMissingTags = async (text: string): Promise => { const hashtagsInText = parseHashtags(text); const existingTagNames = tags.map((tag) => tag.name.toLowerCase()); const missingTags = hashtagsInText.filter( (tagName) => !existingTagNames.includes(tagName.toLowerCase()) ); for (const tagName of missingTags) { try { const newTag = await createTag({ name: tagName }); // Update the global tags store setTags([...tags, newTag]); } catch (error) { console.error(`Failed to create tag "${tagName}":`, error); // Don't fail the entire operation if tag creation fails } } }; const handleSubmit = useCallback(async () => { if (!inputText.trim() || isSaving) return; setIsSaving(true); try { // Create missing tags first await createMissingTags(inputText.trim()); if (editMode && onEdit) { await onEdit(inputText.trim()); setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); }, 300); return; // Exit early to prevent creating duplicates } if (saveMode === 'task') { const newTask: Task = { name: inputText.trim(), status: 'not_started', }; try { await onSave(newTask); showSuccessToast(t('task.createSuccess')); setInputText(''); handleClose(); } catch (error: any) { // If it's an auth error, don't show error toast (user will be redirected) if (isAuthError(error)) { return; } throw error; } } else { try { await createInboxItemWithStore(inputText.trim()); showSuccessToast(t('inbox.itemAdded')); handleClose(); } catch (error) { console.error('Failed to create inbox item:', error); showErrorToast(t('inbox.addError')); setIsSaving(false); } } } catch (error) { console.error('Failed to save:', error); if (editMode) { showErrorToast(t('inbox.updateError')); } else { showErrorToast( saveMode === 'task' ? t('task.createError') : t('inbox.addError') ); } } finally { setIsSaving(false); } }, [ inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose, tags, setTags, ]); const handleClose = useCallback(() => { setIsClosing(true); setTimeout(() => { onClose(); if (!editMode) { setInputText(''); setSaveMode('inbox'); } setIsClosing(false); }, 300); }, [onClose, editMode]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( modalRef.current && !modalRef.current.contains(event.target as Node) ) { if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, showTagSuggestions, handleClose]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, showTagSuggestions, handleClose]); if (!isOpen) return null; return (
{/* Close button - only visible on mobile */}
{ const pos = e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions if (showTagSuggestions) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} onKeyUp={(e) => { const pos = e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions if (showTagSuggestions) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} onClick={(e) => { const pos = e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions if (showTagSuggestions) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} required className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2" placeholder={t('inbox.captureThought')} onKeyDown={(e) => { if ( e.key === 'Enter' && !e.shiftKey && !isSaving && !showTagSuggestions ) { e.preventDefault(); handleSubmit(); } }} /> {/* Tags display like TaskItem */} {inputText && parseHashtags(inputText).length > 0 && (
{parseHashtags(inputText).map( (tagName, index) => { const tag = tags.find( (t) => t.name.toLowerCase() === tagName.toLowerCase() ); const isLast = index === parseHashtags( inputText ).length - 1; if (tag) { return ( e.stopPropagation() } > { tagName } {!isLast && ', '} ); } else { return ( {tagName} {!isLast && ', '} ); } } )}
)} {/* Tag Suggestions Dropdown */} {showTagSuggestions && filteredTags.length > 0 && (
{filteredTags.map((tag, index) => ( ))}
)}
{/* URL Preview disabled */} {/* */}
); }; export default InboxModal;