import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Task } from '../../entities/Task'; import { Tag } from '../../entities/Tag'; import { Project } from '../../entities/Project'; import { Note } from '../../entities/Note'; 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 { createProject } from '../../utils/projectsService'; import { XMarkIcon, TagIcon, FolderIcon } from '@heroicons/react/24/outline'; import { useStore } from '../../store/useStore'; import { Link } from 'react-router-dom'; import { isUrl } from '../../utils/urlService'; // import UrlPreview from "../Shared/UrlPreview"; // import { UrlTitleResult } from "../../utils/urlService"; interface InboxModalProps { isOpen: boolean; onClose: () => void; onSave: (task: Task) => Promise; onSaveNote?: (note: Note) => Promise; // For note creation initialText?: string; editMode?: boolean; onEdit?: (text: string) => Promise; onConvertToTask?: () => Promise; // Called when editing item gets converted to task onConvertToNote?: () => Promise; // Called when editing item gets converted to note projects?: Project[]; // Projects passed as props to avoid duplicate API calls } const InboxModal: React.FC = ({ isOpen, onClose, onSave, onSaveNote, initialText = '', editMode = false, onEdit, onConvertToTask, onConvertToNote, projects: propProjects = [], }) => { 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 } = useStore(); const tags = tagsStore.getTags(); const { setTags } = tagsStore; const [showTagSuggestions, setShowTagSuggestions] = useState(false); const [filteredTags, setFilteredTags] = useState([]); const [showProjectSuggestions, setShowProjectSuggestions] = useState(false); const [filteredProjects, setFilteredProjects] = useState([]); // Use projects from props instead of local state const projects = propProjects; const [cursorPosition, setCursorPosition] = useState(0); const [, setCurrentHashtagQuery] = useState(''); const [, setCurrentProjectQuery] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0, }); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); // const [urlPreview, setUrlPreview] = useState(null); // Real-time text analysis state const [analysisResult, setAnalysisResult] = useState<{ parsed_tags: string[]; parsed_projects: string[]; cleaned_content: string; suggested_type: 'task' | 'note' | null; suggested_reason: string | null; } | null>(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const analysisTimeoutRef = useRef(); // Dispatch global modal events to hide floating + button // Helper function to parse hashtags from text (consecutive groups anywhere) const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { // Check if current word starts a tag/project group if (words[i].startsWith('#') || words[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < words.length && (words[groupEnd].startsWith('#') || words[groupEnd].startsWith('+')) ) { groupEnd++; } // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); if ( tagName && /^[a-zA-Z0-9_-]+$/.test(tagName) && !matches.includes(tagName) ) { matches.push(tagName); } } } // Skip to end of this group i = groupEnd; } else { i++; } } return matches; }; // Helper function to parse project references from text (consecutive groups anywhere) const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; // Tokenize the text handling quoted strings properly const tokens = tokenizeText(trimmedText); // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < tokens.length && (tokens[groupEnd].startsWith('#') || tokens[groupEnd].startsWith('+')) ) { groupEnd++; } // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); // Handle quoted project names if ( projectName.startsWith('"') && projectName.endsWith('"') ) { projectName = projectName.slice(1, -1); } if (projectName && !matches.includes(projectName)) { matches.push(projectName); } } } // Skip to end of this group i = groupEnd; } else { i++; } } return matches; }; // Helper function to tokenize text handling quoted strings const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; let inQuotes = false; let i = 0; while (i < text.length) { const char = text[i]; if (char === '"' && (i === 0 || text[i - 1] === '+')) { // Start of a quoted string after + inQuotes = true; currentToken += char; } else if (char === '"' && inQuotes) { // End of quoted string inQuotes = false; currentToken += char; } else if (char === ' ' && !inQuotes) { // Space outside quotes - end current token if (currentToken) { tokens.push(currentToken); currentToken = ''; } } else { // Regular character currentToken += char; } i++; } // Add final token if (currentToken) { tokens.push(currentToken); } return tokens; }; // Helper function to get current hashtag query at cursor position (only at start/end) const getCurrentHashtagQuery = (text: string, position: number): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); if (!hashtagMatch) return ''; // Check if hashtag is at start or end position const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = text.substring(0, hashtagStart).trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return hashtagMatch[1]; } // Check if we're at the very beginning if (textBeforeHashtag === '') { return hashtagMatch[1]; } // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { return hashtagMatch[1]; } return ''; }; // Helper function to get current project query at cursor position (only at start/end) const getCurrentProjectQuery = (text: string, position: number): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); // Match both quoted and unquoted project references const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); if (!projectMatch) return ''; // Get the project name (from quoted or unquoted match) const projectQuery = projectMatch[1] || projectMatch[2] || ''; // Check if project ref is at start or end position const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = text.substring(0, projectStart).trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return projectQuery; } // Check if we're at the very beginning if (textBeforeProject === '') { return projectQuery; } // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeProject.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { return projectQuery; } return ''; }; // Helper function to remove a tag from the input text const removeTagFromText = (tagToRemove: string) => { const words = inputText.trim().split(/\s+/); const filteredWords = words.filter( (word) => word !== `#${tagToRemove}` ); const newText = filteredWords.join(' ').trim(); setInputText(newText); if (nameInputRef.current) { nameInputRef.current.focus(); } }; // Helper function to remove a project from the input text const removeProjectFromText = (projectToRemove: string) => { const words = inputText.trim().split(/\s+/); const filteredWords = words.filter( (word) => word !== `+${projectToRemove}` ); const newText = filteredWords.join(' ').trim(); setInputText(newText); if (nameInputRef.current) { nameInputRef.current.focus(); } }; // 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 or + for project (only at start/end) const beforeCursor = inputText.substring(0, cursorPos); const afterCursor = inputText.substring(cursorPos); const hashtagMatch = beforeCursor.match(/#[a-zA-Z0-9_]*$/); const projectMatch = beforeCursor.match(/\+[a-zA-Z0-9_\s]*$/); if (hashtagMatch) { const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = inputText .substring(0, hashtagStart) .trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; if (textAfterCursor === '' || textBeforeHashtag === '') { showDropdown = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } if (showDropdown) { // 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, }; } } if (projectMatch) { const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = inputText .substring(0, projectStart) .trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; if (textAfterCursor === '' || textBeforeProject === '') { showDropdown = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeProject.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } if (showDropdown) { // Create temp element for text up to project start const tempToProject = document.createElement('span'); tempToProject.style.visibility = 'hidden'; tempToProject.style.position = 'absolute'; tempToProject.style.fontSize = getComputedStyle(input).fontSize; tempToProject.style.fontFamily = getComputedStyle(input).fontFamily; tempToProject.style.fontWeight = getComputedStyle(input).fontWeight; tempToProject.textContent = inputText.substring( 0, projectStart ); document.body.appendChild(tempToProject); const projectOffset = tempToProject.getBoundingClientRect().width; document.body.removeChild(tempToProject); return { left: projectOffset, top: input.offsetHeight, }; } } return { left: textWidth, top: input.offsetHeight }; }; useEffect(() => { if (isOpen && nameInputRef.current) { nameInputRef.current.focus(); } }, [isOpen]); // Projects are now passed as props, no need to load them // 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); // Check if user is typing a project reference const projectQuery = getCurrentProjectQuery(newText, newCursorPosition); setCurrentProjectQuery(projectQuery); // Only show suggestions if hashtag/project is at start or end if ( (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) && hashtagQuery !== '' ) { // Hide project suggestions when showing tag suggestions setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); // 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); setSelectedSuggestionIndex(-1); } else if ( (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '' ) { // Hide tag suggestions when showing project suggestions setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); // Filter projects based on current query const filtered = projects .filter((project) => project.name .toLowerCase() .includes(projectQuery.toLowerCase()) ) .slice(0, 5); // Limit to 5 suggestions // Calculate dropdown position const position = calculateDropdownPosition( e.target, newCursorPosition ); setDropdownPosition(position); setFilteredProjects(filtered); setShowProjectSuggestions(true); setSelectedSuggestionIndex(-1); } else { setShowTagSuggestions(false); setFilteredTags([]); setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); } }; // Helper function to get all tags including auto-detected bookmark const getAllTags = (text: string): string[] => { // Use analysis result if available, otherwise fall back to local parsing if (analysisResult) { const explicitTags = analysisResult.parsed_tags; // Auto-add bookmark if text contains URL or backend suggests URL note const isUrlContent = isUrl(text.trim()) || analysisResult.suggested_reason === 'url_detected'; if (isUrlContent) { const hasBookmarkTag = explicitTags.some( (tag) => tag.toLowerCase() === 'bookmark' ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } return explicitTags; } // Fallback to local parsing const explicitTags = parseHashtags(text); // Auto-add bookmark if text contains URL and bookmark tag isn't already present if (isUrl(text.trim())) { const hasBookmarkTag = explicitTags.some( (tag) => tag.toLowerCase() === 'bookmark' ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } return explicitTags; }; // Helper function to get all project references const getAllProjects = (text: string): string[] => { // Use analysis result if available, otherwise fall back to local parsing if (analysisResult) { return analysisResult.parsed_projects; } // Fallback to local parsing return parseProjectRefs(text); }; // Helper function to get cleaned content const getCleanedContent = (text: string): string => { // Use analysis result if available, otherwise fall back to local cleaning if (analysisResult) { return analysisResult.cleaned_content; } // Fallback to local cleaning (simplified version) return text .replace(/#[a-zA-Z0-9_-]+/g, '') .replace(/\+\S+/g, '') .trim(); }; // Helper function to get suggestion const getSuggestion = (): { type: 'note' | 'task' | null; message: string | null; projectName: string | null; } => { if (!analysisResult || !analysisResult.suggested_type) { return { type: null, message: null, projectName: null }; } const projectName = analysisResult.parsed_projects[0] || null; const type = analysisResult.suggested_type; if (type === 'note') { // Check if this is a URL (bookmark) note const isUrlNote = analysisResult.suggested_reason === 'url_detected'; const message = isUrlNote ? `This item will be saved as a bookmark note for ${projectName}.` : `This item will be saved for later processing as it looks like a note for ${projectName}.`; return { type: 'note', message, projectName, }; } else if (type === 'task') { return { type: 'task', message: `This item looks like a task and will be created under project ${projectName}.`, projectName, }; } return { type: null, message: null, projectName: null }; }; // Debounced text analysis function const analyzeText = useCallback(async (text: string) => { if (!text.trim()) { setAnalysisResult(null); return; } try { setIsAnalyzing(true); const response = await fetch('/api/inbox/analyze-text', { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ content: text }), }); if (response.ok) { const result = await response.json(); setAnalysisResult(result); } else { console.error('Failed to analyze text:', response.statusText); setAnalysisResult(null); } } catch (error) { console.error('Error analyzing text:', error); setAnalysisResult(null); } finally { setIsAnalyzing(false); } }, []); // Debounced text analysis effect useEffect(() => { if (analysisTimeoutRef.current) { clearTimeout(analysisTimeoutRef.current); } analysisTimeoutRef.current = setTimeout(() => { analyzeText(inputText); }, 300); // 300ms debounce return () => { if (analysisTimeoutRef.current) { clearTimeout(analysisTimeoutRef.current); } }; }, [inputText, analyzeText]); // 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 hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = inputText .substring(0, hashtagStart) .trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; if (textAfterCursor === '' || textBeforeHashtag === '') { allowReplacement = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { allowReplacement = true; } } if (allowReplacement) { const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + afterCursor; setInputText(newText); setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); // 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); } } }; // Handle project suggestion selection const handleProjectSelect = (projectName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); // Match both quoted and unquoted project references const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); if (projectMatch) { const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = inputText .substring(0, projectStart) .trim(); const textAfterCursor = afterCursor.trim(); // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; if (textAfterCursor === '' || textBeforeProject === '') { allowReplacement = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeProject.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { allowReplacement = true; } } if (allowReplacement) { // Automatically add quotes if project name contains spaces const formattedProjectName = projectName.includes(' ') ? `"${projectName}"` : projectName; const newText = beforeCursor.replace( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, `+${formattedProjectName}` ) + afterCursor; setInputText(newText); setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); // Focus back on input and set cursor position setTimeout(() => { if (nameInputRef.current) { nameInputRef.current.focus(); const newCursorPos = beforeCursor.replace( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, `+${formattedProjectName}` ).length; nameInputRef.current.setSelectionRange( newCursorPos, newCursorPos ); } }, 0); } } }; // Helper function to clean text by removing tags and project references at start/end const cleanTextFromTagsAndProjects = (text: string): string => { const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); const cleanedTokens: string[] = []; let i = 0; while (i < tokens.length) { // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Skip this entire consecutive group while ( i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+')) ) { i++; } } else { // Keep regular tokens cleanedTokens.push(tokens[i]); i++; } } return cleanedTokens.join(' ').trim(); }; // Create missing tags automatically const createMissingTags = async (text: string): Promise => { const hashtagsInText = getAllTags(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 } } }; // Create missing projects automatically const createMissingProjects = async (text: string): Promise => { const projectsInText = getAllProjects(text); const existingProjectNames = projects.map((project) => project.name.toLowerCase() ); const missingProjects = projectsInText.filter( (projectName) => !existingProjectNames.includes(projectName.toLowerCase()) ); for (const projectName of missingProjects) { try { await createProject({ name: projectName, active: true, }); // Projects are managed by the parent component through props // No need to update local state } catch (error) { console.error( `Failed to create project "${projectName}":`, error ); // Don't fail the entire operation if project creation fails } } }; const handleSubmit = useCallback( async (forceInbox = false) => { if (!inputText.trim() || isSaving) return; setIsSaving(true); try { // Check if suggestions are present first, even in edit mode (unless forced to inbox mode) if (analysisResult?.suggested_type === 'task' && !forceInbox) { // Auto-convert to task using the same logic as convert to task action await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); const cleanedText = getCleanedContent(inputText.trim()); // Convert parsed tags to Tag objects const taskTags = analysisResult.parsed_tags.map( (tagName) => { // Find existing tag or create a placeholder for new tag const existingTag = tags.find( (tag) => tag.name.toLowerCase() === tagName.toLowerCase() ); return existingTag || { name: tagName }; } ); // Find the project to assign (use first project reference if any) let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { // Look for an existing project with the first project reference name const projectName = analysisResult.parsed_projects[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } const newTask: Task = { name: cleanedText, status: 'not_started', priority: 'low', tags: taskTags, project_id: projectId, completed_at: null, }; try { await onSave(newTask); showSuccessToast(t('task.createSuccess')); // If in edit mode, we need to mark the original inbox item as processed if (editMode && onConvertToTask) { await onConvertToTask(); } setInputText(''); handleClose(); return; } catch (error: any) { if (isAuthError(error)) { return; } throw error; } } // Check if it's a note suggestion (bookmark + project) (unless forced to inbox mode) if (analysisResult?.suggested_type === 'note' && !forceInbox) { // Auto-convert to note using similar logic await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); const cleanedText = getCleanedContent(inputText.trim()); // Convert parsed tags to Tag objects and include bookmark tag const hashtagTags = analysisResult.parsed_tags.map( (tagName) => { const existingTag = tags.find( (tag) => tag.name.toLowerCase() === tagName.toLowerCase() ); return existingTag || { name: tagName }; } ); // Add bookmark tag for URLs or when suggested reason is url_detected const isUrlContent = isUrl(inputText.trim()) || analysisResult.suggested_reason === 'url_detected'; const bookmarkTag = isUrlContent ? [{ name: 'bookmark' }] : []; // Make sure we don't duplicate bookmark tag if it's already in parsed tags const hasBookmarkInParsed = hashtagTags.some( (tag) => tag.name.toLowerCase() === 'bookmark' ); const finalBookmarkTag = hasBookmarkInParsed ? [] : bookmarkTag; const taskTags = [...hashtagTags, ...finalBookmarkTag]; // Find the project to assign let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { const projectName = analysisResult.parsed_projects[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } const newNote: Note = { title: cleanedText || inputText.trim(), content: inputText.trim(), tags: taskTags, project_id: projectId, }; try { if (onSaveNote) { await onSaveNote(newNote); showSuccessToast( t( 'note.createSuccess', 'Note created successfully' ) ); // If in edit mode, we need to mark the original inbox item as processed if (editMode && onConvertToNote) { await onConvertToNote(); } setInputText(''); handleClose(); return; } else { // If no note creation handler, fall back to inbox mode console.log( 'No note creation handler, falling back to inbox' ); } } catch (error: any) { console.error('Error in note creation flow:', error); if (isAuthError(error)) { return; } throw error; } } if (editMode && onEdit) { // For edit mode, store the original text with tags/projects await onEdit(inputText.trim()); setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); }, 300); return; // Exit early to prevent creating duplicates } const effectiveSaveMode = saveMode; if (effectiveSaveMode === 'task') { // For task mode, create missing tags and projects, then clean the text await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); const cleanedText = cleanTextFromTagsAndProjects( inputText.trim() ); const newTask: Task = { name: cleanedText, status: 'not_started', completed_at: null, }; 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 { console.log('Taking inbox creation path'); try { // For inbox mode, store the original text with tags/projects // Tags and projects will be created and assigned when the item is processed later 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, analysisResult, createMissingTags, createMissingProjects, getCleanedContent, projects, ] ); 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([]); setSelectedSuggestionIndex(-1); } else if (showProjectSuggestions) { setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, showTagSuggestions, showProjectSuggestions, handleClose]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); } else if (showProjectSuggestions) { setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, showTagSuggestions, showProjectSuggestions, 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 || showProjectSuggestions ) { 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 || showProjectSuggestions ) { 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 || showProjectSuggestions ) { 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) => { // Handle dropdown navigation if ( showTagSuggestions && filteredTags.length > 0 ) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev < filteredTags.length - 1 ? prev + 1 : 0 ); return; } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev > 0 ? prev - 1 : filteredTags.length - 1 ); return; } else if (e.key === 'Tab') { e.preventDefault(); const selectedTag = selectedSuggestionIndex >= 0 ? filteredTags[ selectedSuggestionIndex ] : filteredTags[0]; handleTagSelect( selectedTag.name ); return; } else if ( e.key === 'Enter' && selectedSuggestionIndex >= 0 ) { e.preventDefault(); handleTagSelect( filteredTags[ selectedSuggestionIndex ].name ); return; } else if (e.key === 'Escape') { e.preventDefault(); setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); return; } } // Handle project dropdown navigation if ( showProjectSuggestions && filteredProjects.length > 0 ) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev < filteredProjects.length - 1 ? prev + 1 : 0 ); return; } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev > 0 ? prev - 1 : filteredProjects.length - 1 ); return; } else if (e.key === 'Tab') { e.preventDefault(); const selectedProject = selectedSuggestionIndex >= 0 ? filteredProjects[ selectedSuggestionIndex ] : filteredProjects[0]; handleProjectSelect( selectedProject.name ); return; } else if ( e.key === 'Enter' && selectedSuggestionIndex >= 0 ) { e.preventDefault(); handleProjectSelect( filteredProjects[ selectedSuggestionIndex ].name ); return; } else if (e.key === 'Escape') { e.preventDefault(); setShowProjectSuggestions( false ); setFilteredProjects([]); setSelectedSuggestionIndex(-1); return; } } // Handle form submission if ( e.key === 'Enter' && !e.shiftKey && !isSaving ) { // If suggestions are showing and there are filtered options, don't submit if ( (showTagSuggestions && filteredTags.length > 0) || (showProjectSuggestions && filteredProjects.length > 0) ) { // Don't submit, let the user select from suggestions return; } // Otherwise, submit the form e.preventDefault(); handleSubmit(); } }} /> {/* Tags display like TaskItem */} {inputText && getAllTags(inputText).length > 0 && (
{getAllTags(inputText).map( (tagName, index) => { const tag = tags.find( (t) => t.name.toLowerCase() === tagName.toLowerCase() ); if (tag) { return ( e.stopPropagation() } > { tagName } ); } else { return ( {tagName} ); } } )}
)} {/* Projects display like TaskItem */} {inputText && getAllProjects(inputText).length > 0 && (
{getAllProjects(inputText).map( (projectName, index) => { const project = projects.find( (p) => p.name.toLowerCase() === projectName.toLowerCase() ); if (project) { return ( e.stopPropagation() } > { projectName } ); } else { return ( { projectName } ); } } )}
)} {/* Tag Suggestions Dropdown */} {showTagSuggestions && filteredTags.length > 0 && (
{filteredTags.map((tag, index) => ( ))}
)} {/* Project Suggestions Dropdown */} {showProjectSuggestions && filteredProjects.length > 0 && (
{filteredProjects.map( (project, index) => ( ) )}
)} {/* Intelligent Suggestion */} {(() => { const suggestion = getSuggestion(); return suggestion.type && suggestion.message ? (
{/* AI Stars Icon */}

{suggestion.message}

or
{isAnalyzing && (
)}
) : null; })()}
{/* URL Preview disabled */} {/* */}
); }; export default InboxModal;