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 { useModalEvents } from "../../hooks/useModalEvents"; 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 [currentHashtagQuery, setCurrentHashtagQuery] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0 }); // const [urlPreview, setUrlPreview] = useState(null); // Dispatch global modal events to hide floating + button useModalEvents(isOpen); // 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;