From 04d39b07e9e076d4d83d5cb29d07fbd115c254a2 Mon Sep 17 00:00:00 2001 From: Chris Veleris Date: Fri, 11 Jul 2025 16:14:12 +0300 Subject: [PATCH] Transfer tags and project to modals --- frontend/components/Inbox/InboxItemDetail.tsx | 184 ++++- frontend/components/Inbox/InboxItems.tsx | 39 +- frontend/components/Inbox/InboxModal.tsx | 661 ++++++++++++++++-- frontend/components/Note/NoteModal.tsx | 42 +- package-lock.json | 28 +- package.json | 2 +- 6 files changed, 836 insertions(+), 120 deletions(-) diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index 296d876..b14d610 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -23,6 +23,7 @@ interface InboxItemDetailProps { openTaskModal: (task: Task, inboxItemId?: number) => void; openProjectModal: (project: Project | null, inboxItemId?: number) => void; openNoteModal: (note: Note | null, inboxItemId?: number) => void; + projects: Project[]; } const InboxItemDetail: React.FC = ({ @@ -33,6 +34,7 @@ const InboxItemDetail: React.FC = ({ openTaskModal, openProjectModal, openNoteModal, + projects, }) => { const { t } = useTranslation(); const { @@ -42,14 +44,117 @@ const InboxItemDetail: React.FC = ({ const [loading, setLoading] = useState(false); const [isHovered, setIsHovered] = useState(false); - // Helper function to parse hashtags from text + // Helper function to parse hashtags from text (only at start and end) 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)) : []; + const trimmedText = text.trim(); + const matches: string[] = []; + + // Split text into words + const words = trimmedText.split(/\s+/); + if (words.length === 0) return matches; + + // Check for hashtags at the beginning (consecutive tags/projects only) + let startIndex = 0; + while (startIndex < words.length && (words[startIndex].startsWith('#') || words[startIndex].startsWith('+'))) { + if (words[startIndex].startsWith('#')) { + const tagName = words[startIndex].substring(1); + if (tagName && /^[a-zA-Z0-9_]+$/.test(tagName)) { + matches.push(tagName); + } + } + startIndex++; + } + + // Check for hashtags at the end (consecutive tags/projects only) + let endIndex = words.length - 1; + while (endIndex >= 0 && (words[endIndex].startsWith('#') || words[endIndex].startsWith('+'))) { + if (words[endIndex].startsWith('#')) { + const tagName = words[endIndex].substring(1); + if (tagName && /^[a-zA-Z0-9_]+$/.test(tagName)) { + // Only add if not already added from the beginning + if (!matches.includes(tagName)) { + matches.push(tagName); + } + } + } + endIndex--; + } + + return matches; + }; + + // Helper function to parse project references from text (only at start and end) + const parseProjectRefs = (text: string): string[] => { + const trimmedText = text.trim(); + const matches: string[] = []; + + // Split text into words/phrases for project references + const words = trimmedText.split(/\s+/); + if (words.length === 0) return matches; + + // Check for project references at the beginning (consecutive tags/projects only) + let startIndex = 0; + while (startIndex < words.length && (words[startIndex].startsWith('+') || words[startIndex].startsWith('#'))) { + if (words[startIndex].startsWith('+')) { + const projectName = words[startIndex].substring(1); + if (projectName && /^[a-zA-Z0-9_\s]+$/.test(projectName)) { + matches.push(projectName); + } + } + startIndex++; + } + + // Check for project references at the end (consecutive tags/projects only) + let endIndex = words.length - 1; + while (endIndex >= 0 && (words[endIndex].startsWith('+') || words[endIndex].startsWith('#'))) { + if (words[endIndex].startsWith('+')) { + const projectName = words[endIndex].substring(1); + if (projectName && /^[a-zA-Z0-9_\s]+$/.test(projectName)) { + // Only add if not already added from the beginning + if (!matches.includes(projectName)) { + matches.push(projectName); + } + } + } + endIndex--; + } + + return matches; + }; + + // Helper function to clean text by removing tags and project references at start/end + const cleanTextFromTagsAndProjects = (text: string): string => { + const trimmedText = text.trim(); + const words = trimmedText.split(/\s+/); + + if (words.length === 0) return ''; + + // Find the start and end indices of actual content (non-tags/projects) + let startIndex = 0; + let endIndex = words.length - 1; + + // Skip tags and projects at the beginning + while (startIndex < words.length && (words[startIndex].startsWith('#') || words[startIndex].startsWith('+'))) { + startIndex++; + } + + // Skip tags and projects at the end + while (endIndex >= 0 && (words[endIndex].startsWith('#') || words[endIndex].startsWith('+'))) { + endIndex--; + } + + // If all words are tags/projects, return empty string + if (startIndex > endIndex) { + return ''; + } + + // Return the cleaned content + return words.slice(startIndex, endIndex + 1).join(' ').trim(); }; const hashtags = parseHashtags(item.content); + const projectRefs = parseProjectRefs(item.content); + const cleanedContent = cleanTextFromTagsAndProjects(item.content); const handleConvertToTask = () => { // Convert hashtags to Tag objects @@ -61,11 +166,25 @@ const InboxItemDetail: React.FC = ({ return existingTag || { name: hashtagName }; }); + // Find the project to assign (use first project reference if any) + let projectId = undefined; + if (projectRefs.length > 0) { + // Look for an existing project with the first project reference name + const projectName = projectRefs[0]; + const matchingProject = projects.find( + (project) => project.name.toLowerCase() === projectName.toLowerCase() + ); + if (matchingProject) { + projectId = matchingProject.id; + } + } + const newTask: Task = { - name: item.content, + name: cleanedContent || item.content, status: 'not_started', priority: 'medium', tags: taskTags, + project_id: projectId, }; if (item.id !== undefined) { @@ -76,7 +195,7 @@ const InboxItemDetail: React.FC = ({ }; const handleConvertToProject = () => { - // Convert hashtags to Tag objects + // Convert hashtags to Tag objects (ignore any existing project references) const projectTags = hashtags.map((hashtagName) => { // Find existing tag or create a placeholder for new tag const existingTag = tags.find( @@ -86,7 +205,7 @@ const InboxItemDetail: React.FC = ({ }); const newProject: Project = { - name: item.content, + name: cleanedContent || item.content, description: '', active: true, tags: projectTags, @@ -159,11 +278,30 @@ const InboxItemDetail: React.FC = ({ const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : []; const tagObjects = [...hashtagTags, ...bookmarkTag]; + // Use cleaned content for note title if no URL title was extracted + const finalTitle = title === content ? (cleanedContent || item.content) : title; + const finalContent = cleanedContent || item.content; + + // Find the project to assign (use first project reference if any) + let projectId = undefined; + if (projectRefs.length > 0) { + // Look for an existing project with the first project reference name + const projectName = projectRefs[0]; + const matchingProject = projects.find( + (project) => project.name.toLowerCase() === projectName.toLowerCase() + ); + if (matchingProject) { + projectId = matchingProject.id; + } + } + const newNote: Note = { - title: title, - content: content, + title: finalTitle, + content: finalContent, tags: tagObjects, + project_id: projectId, }; + if (item.id !== undefined) { openNoteModal(newNote, item.id); @@ -192,14 +330,32 @@ const InboxItemDetail: React.FC = ({

- {item.content} + {cleanedContent || item.content}

- {/* Tags display */} - {hashtags.length > 0 && ( + {/* Tags and Projects display - TaskHeader style */} + {(hashtags.length > 0 || projectRefs.length > 0) && (
- - {hashtags.join(', ')} + {/* Projects display first */} + {projectRefs.length > 0 && ( +
+ + {projectRefs.join(', ')} +
+ )} + + {/* Add spacing between project and tags */} + {projectRefs.length > 0 && hashtags.length > 0 && ( + + )} + + {/* Tags display */} + {hashtags.length > 0 && ( +
+ + {hashtags.join(', ')} +
+ )}
)}
diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index 6511929..f87b7d4 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -57,6 +57,18 @@ const InboxItems: React.FC = () => { useEffect(() => { // Initial data loading loadInboxItemsToStore(true); + + // Load projects initially + const loadInitialProjects = async () => { + try { + const projectData = await fetchProjects(); + setProjects(Array.isArray(projectData) ? projectData : []); + } catch (error) { + console.error('Failed to load initial projects:', error); + setProjects([]); + } + }; + loadInitialProjects(); // Set up an event listener for force reload const handleForceReload = () => { @@ -201,18 +213,7 @@ const InboxItems: React.FC = () => { note: Note | null, inboxItemId?: number ) => { - // Load projects first before opening the modal - try { - const projectData = await fetchProjects(); - // Make sure we always set an array - setProjects(Array.isArray(projectData) ? projectData : []); - } catch (error) { - console.error('Failed to load projects:', error); - showErrorToast(t('project.loadError', 'Failed to load projects')); - setProjects([]); // Ensure we have an empty array even on error - } - - // If note has content that's a URL, ensure it has a bookmark tag + // Set up the note data first if (note && note.content && isUrl(note.content.trim())) { if (!note.tags) { note.tags = [{ name: 'bookmark' }]; @@ -227,6 +228,19 @@ const InboxItems: React.FC = () => { setCurrentConversionItemId(inboxItemId); } + // Projects should already be loaded from initial useEffect, + // but refresh them if they're empty as a fallback + if (projects.length === 0) { + try { + const projectData = await fetchProjects(); + setProjects(Array.isArray(projectData) ? projectData : []); + } catch (error) { + console.error('Failed to load projects:', error); + showErrorToast(t('project.loadError', 'Failed to load projects')); + setProjects([]); + } + } + setIsNoteModalOpen(true); }; @@ -403,6 +417,7 @@ const InboxItems: React.FC = () => { openTaskModal={handleOpenTaskModal} openProjectModal={handleOpenProjectModal} openNoteModal={handleOpenNoteModal} + projects={projects} /> ))}
diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index 5ddf16d..4228264 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -1,12 +1,14 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Task } from '../../entities/Task'; import { Tag } from '../../entities/Tag'; +import { Project } from '../../entities/Project'; 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 { fetchProjects, 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 UrlPreview from "../Shared/UrlPreview"; @@ -42,8 +44,12 @@ const InboxModal: React.FC = ({ } = useStore(); const [showTagSuggestions, setShowTagSuggestions] = useState(false); const [filteredTags, setFilteredTags] = useState([]); + const [showProjectSuggestions, setShowProjectSuggestions] = useState(false); + const [filteredProjects, setFilteredProjects] = useState([]); + const [projects, setProjects] = useState([]); const [cursorPosition, setCursorPosition] = useState(0); const [, setCurrentHashtagQuery] = useState(''); + const [, setCurrentProjectQuery] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0, @@ -52,18 +58,174 @@ const InboxModal: React.FC = ({ // Dispatch global modal events to hide floating + button - // Helper function to parse hashtags from text + // Helper function to parse hashtags from text (only at start and end) 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)) : []; + const trimmedText = text.trim(); + const matches: string[] = []; + + // Split text into words + const words = trimmedText.split(/\s+/); + if (words.length === 0) return matches; + + // Check for hashtags at the beginning (consecutive tags/projects only) + let startIndex = 0; + while (startIndex < words.length && (words[startIndex].startsWith('#') || words[startIndex].startsWith('+'))) { + if (words[startIndex].startsWith('#')) { + const tagName = words[startIndex].substring(1); + if (tagName && /^[a-zA-Z0-9_]+$/.test(tagName)) { + matches.push(tagName); + } + } + startIndex++; + } + + // Check for hashtags at the end (consecutive tags/projects only) + let endIndex = words.length - 1; + while (endIndex >= 0 && (words[endIndex].startsWith('#') || words[endIndex].startsWith('+'))) { + if (words[endIndex].startsWith('#')) { + const tagName = words[endIndex].substring(1); + if (tagName && /^[a-zA-Z0-9_]+$/.test(tagName)) { + // Only add if not already added from the beginning + if (!matches.includes(tagName)) { + matches.push(tagName); + } + } + } + endIndex--; + } + + return matches; }; - // Helper function to get current hashtag query at cursor position + // Helper function to parse project references from text (only at start and end) + const parseProjectRefs = (text: string): string[] => { + const trimmedText = text.trim(); + const matches: string[] = []; + + // Split text into words/phrases for project references + const words = trimmedText.split(/\s+/); + if (words.length === 0) return matches; + + // Check for project references at the beginning (consecutive tags/projects only) + let startIndex = 0; + while (startIndex < words.length && (words[startIndex].startsWith('+') || words[startIndex].startsWith('#'))) { + if (words[startIndex].startsWith('+')) { + const projectName = words[startIndex].substring(1); + if (projectName && /^[a-zA-Z0-9_\s]+$/.test(projectName)) { + matches.push(projectName); + } + } + startIndex++; + } + + // Check for project references at the end (consecutive tags/projects only) + let endIndex = words.length - 1; + while (endIndex >= 0 && (words[endIndex].startsWith('+') || words[endIndex].startsWith('#'))) { + if (words[endIndex].startsWith('+')) { + const projectName = words[endIndex].substring(1); + if (projectName && /^[a-zA-Z0-9_\s]+$/.test(projectName)) { + // Only add if not already added from the beginning + if (!matches.includes(projectName)) { + matches.push(projectName); + } + } + } + endIndex--; + } + + return matches; + }; + + // 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_]*)$/); - return hashtagMatch ? hashtagMatch[1] : ''; + + 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); + const projectMatch = beforeCursor.match(/\+([a-zA-Z0-9_\s]*)$/); + + if (!projectMatch) return ''; + + // 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 projectMatch[1]; + } + + // Check if we're at the very beginning + if (textBeforeProject === '') { + return projectMatch[1]; + } + + // 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 projectMatch[1]; + } + + 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 @@ -110,30 +272,92 @@ const InboxModal: React.FC = ({ const textWidth = temp.getBoundingClientRect().width; document.body.removeChild(temp); - // Get the # position for the current hashtag + // 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); - // 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); - document.body.appendChild(tempToHashtag); - const hashtagOffset = tempToHashtag.getBoundingClientRect().width; - document.body.removeChild(tempToHashtag); + return { + left: hashtagOffset, + top: input.offsetHeight, + }; + } + } - 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 }; @@ -145,6 +369,22 @@ const InboxModal: React.FC = ({ } }, [isOpen]); + // Load projects when modal opens + useEffect(() => { + if (isOpen) { + const loadProjects = async () => { + try { + const projectsData = await fetchProjects(); + setProjects(Array.isArray(projectsData) ? projectsData : []); + } catch (error) { + console.error('Failed to load projects:', error); + setProjects([]); + } + }; + loadProjects(); + } + }, [isOpen]); + // Prevent body scroll when modal is open useEffect(() => { if (isOpen) { @@ -170,7 +410,16 @@ const InboxModal: React.FC = ({ const hashtagQuery = getCurrentHashtagQuery(newText, newCursorPosition); setCurrentHashtagQuery(hashtagQuery); - if (newText.charAt(newCursorPosition - 1) === '#' || 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([]); + // Filter tags based on current query const filtered = tags .filter((tag) => @@ -189,9 +438,34 @@ const InboxModal: React.FC = ({ setFilteredTags(filtered); setShowTagSuggestions(true); + } else if ((newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '') { + // Hide tag suggestions when showing project suggestions + setShowTagSuggestions(false); + setFilteredTags([]); + + // 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); } else { setShowTagSuggestions(false); setFilteredTags([]); + setShowProjectSuggestions(false); + setFilteredProjects([]); } }; @@ -202,30 +476,133 @@ const InboxModal: React.FC = ({ 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 - ); + 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; } - }, 0); + } + + if (allowReplacement) { + 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); + } } }; + // Handle project suggestion selection + const handleProjectSelect = (projectName: string) => { + const beforeCursor = inputText.substring(0, cursorPosition); + const afterCursor = inputText.substring(cursorPosition); + 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) { + const newText = + beforeCursor.replace(/\+([a-zA-Z0-9_\s]*)$/, `+${projectName}`) + + afterCursor; + setInputText(newText); + setShowProjectSuggestions(false); + setFilteredProjects([]); + + // Focus back on input and set cursor position + setTimeout(() => { + if (nameInputRef.current) { + nameInputRef.current.focus(); + const newCursorPos = beforeCursor.replace( + /\+([a-zA-Z0-9_\s]*)$/, + `+${projectName}` + ).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 words = trimmedText.split(/\s+/); + + if (words.length === 0) return ''; + + // Find the start and end indices of actual content (non-tags/projects) + let startIndex = 0; + let endIndex = words.length - 1; + + // Skip tags and projects at the beginning + while (startIndex < words.length && (words[startIndex].startsWith('#') || words[startIndex].startsWith('+'))) { + startIndex++; + } + + // Skip tags and projects at the end + while (endIndex >= 0 && (words[endIndex].startsWith('#') || words[endIndex].startsWith('+'))) { + endIndex--; + } + + // If all words are tags/projects, return empty string + if (startIndex > endIndex) { + return ''; + } + + // Return the cleaned content + return words.slice(startIndex, endIndex + 1).join(' ').trim(); + }; + // Create missing tags automatically const createMissingTags = async (text: string): Promise => { const hashtagsInText = parseHashtags(text); @@ -246,16 +623,34 @@ const InboxModal: React.FC = ({ } }; + // Create missing projects automatically + const createMissingProjects = async (text: string): Promise => { + const projectsInText = parseProjectRefs(text); + const existingProjectNames = projects.map((project) => project.name.toLowerCase()); + const missingProjects = projectsInText.filter( + (projectName) => !existingProjectNames.includes(projectName.toLowerCase()) + ); + + for (const projectName of missingProjects) { + try { + const newProject = await createProject({ name: projectName, active: true }); + // Update the local projects state + setProjects([...projects, newProject]); + } catch (error) { + console.error(`Failed to create project "${projectName}":`, error); + // Don't fail the entire operation if project 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) { + // For edit mode, store the original text with tags/projects await onEdit(inputText.trim()); setIsClosing(true); setTimeout(() => { @@ -266,8 +661,13 @@ const InboxModal: React.FC = ({ } if (saveMode === '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: inputText.trim(), + name: cleanedText, status: 'not_started', }; @@ -285,6 +685,8 @@ const InboxModal: React.FC = ({ } } else { 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')); @@ -323,6 +725,8 @@ const InboxModal: React.FC = ({ onClose, tags, setTags, + projects, + setProjects, ]); const handleClose = useCallback(() => { @@ -346,6 +750,9 @@ const InboxModal: React.FC = ({ if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); + } else if (showProjectSuggestions) { + setShowProjectSuggestions(false); + setFilteredProjects([]); } else { handleClose(); } @@ -357,7 +764,7 @@ const InboxModal: React.FC = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isOpen, showTagSuggestions, handleClose]); + }, [isOpen, showTagSuggestions, showProjectSuggestions, handleClose]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -365,6 +772,9 @@ const InboxModal: React.FC = ({ if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); + } else if (showProjectSuggestions) { + setShowProjectSuggestions(false); + setFilteredProjects([]); } else { handleClose(); } @@ -376,7 +786,7 @@ const InboxModal: React.FC = ({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [isOpen, showTagSuggestions, handleClose]); + }, [isOpen, showTagSuggestions, showProjectSuggestions, handleClose]); if (!isOpen) return null; @@ -416,7 +826,7 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions) { + if (showTagSuggestions || showProjectSuggestions) { const position = calculateDropdownPosition( e.currentTarget, @@ -430,7 +840,7 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions) { + if (showTagSuggestions || showProjectSuggestions) { const position = calculateDropdownPosition( e.currentTarget, @@ -444,7 +854,7 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions) { + if (showTagSuggestions || showProjectSuggestions) { const position = calculateDropdownPosition( e.currentTarget, @@ -457,12 +867,15 @@ const InboxModal: React.FC = ({ 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 - ) { + if (e.key === 'Enter' && !e.shiftKey && !isSaving) { + // If suggestions are showing and there are filtered options, let the user navigate + 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(); } @@ -472,9 +885,9 @@ const InboxModal: React.FC = ({ {/* Tags display like TaskItem */} {inputText && parseHashtags(inputText).length > 0 && ( -
+
- +
{parseHashtags(inputText).map( (tagName, index) => { const tag = tags.find( @@ -482,50 +895,116 @@ const InboxModal: React.FC = ({ t.name.toLowerCase() === tagName.toLowerCase() ); - const isLast = - index === - parseHashtags( - inputText - ).length - - 1; if (tag) { return ( e.stopPropagation() } > - { - tagName - } + {tagName} - {!isLast && - ', '} + ); } else { return ( {tagName} - {!isLast && - ', '} + ); } } )} - +
+
+ )} + + {/* Projects display like TaskItem */} + {inputText && + parseProjectRefs(inputText).length > 0 && ( +
+ +
+ {parseProjectRefs(inputText).map( + (projectName, index) => { + const project = projects.find( + (p) => + p.name.toLowerCase() === + projectName.toLowerCase() + ); + + if (project) { + return ( + + + e.stopPropagation() + } + > + {projectName} + + + + ); + } else { + return ( + + {projectName} + + + ); + } + } + )} +
)} @@ -556,6 +1035,34 @@ const InboxModal: React.FC = ({ ))}
)} + + {/* Project Suggestions Dropdown */} + {showProjectSuggestions && + filteredProjects.length > 0 && ( +
+ {filteredProjects.map((project, index) => ( + + ))} +
+ )}