Transfer tags and project to modals
This commit is contained in:
parent
3375322bea
commit
04d39b07e9
6 changed files with 836 additions and 120 deletions
|
|
@ -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<InboxItemDetailProps> = ({
|
||||
|
|
@ -33,6 +34,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
openTaskModal,
|
||||
openProjectModal,
|
||||
openNoteModal,
|
||||
projects,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
|
|
@ -42,14 +44,117 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
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<InboxItemDetailProps> = ({
|
|||
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<InboxItemDetailProps> = ({
|
|||
};
|
||||
|
||||
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<InboxItemDetailProps> = ({
|
|||
});
|
||||
|
||||
const newProject: Project = {
|
||||
name: item.content,
|
||||
name: cleanedContent || item.content,
|
||||
description: '',
|
||||
active: true,
|
||||
tags: projectTags,
|
||||
|
|
@ -159,11 +278,30 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
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<InboxItemDetailProps> = ({
|
|||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
|
||||
{item.content}
|
||||
{cleanedContent || item.content}
|
||||
</p>
|
||||
|
||||
{/* Tags display */}
|
||||
{hashtags.length > 0 && (
|
||||
{/* Tags and Projects display - TaskHeader style */}
|
||||
{(hashtags.length > 0 || projectRefs.length > 0) && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>{hashtags.join(', ')}</span>
|
||||
{/* Projects display first */}
|
||||
{projectRefs.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<span>{projectRefs.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add spacing between project and tags */}
|
||||
{projectRefs.length > 0 && hashtags.length > 0 && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
|
||||
{/* Tags display */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>{hashtags.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<InboxModalProps> = ({
|
|||
} = useStore();
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
|
||||
const [showProjectSuggestions, setShowProjectSuggestions] = useState(false);
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
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<InboxModalProps> = ({
|
|||
|
||||
// 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<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
}
|
||||
}, [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<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
|
||||
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<InboxModalProps> = ({
|
|||
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<void> => {
|
||||
const hashtagsInText = parseHashtags(text);
|
||||
|
|
@ -246,16 +623,34 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Create missing projects automatically
|
||||
const createMissingProjects = async (text: string): Promise<void> => {
|
||||
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<InboxModalProps> = ({
|
|||
}
|
||||
|
||||
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<InboxModalProps> = ({
|
|||
}
|
||||
} 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<InboxModalProps> = ({
|
|||
onClose,
|
||||
tags,
|
||||
setTags,
|
||||
projects,
|
||||
setProjects,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
|
|
@ -346,6 +750,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
if (showTagSuggestions) {
|
||||
setShowTagSuggestions(false);
|
||||
setFilteredTags([]);
|
||||
} else if (showProjectSuggestions) {
|
||||
setShowProjectSuggestions(false);
|
||||
setFilteredProjects([]);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
|
|
@ -357,7 +764,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
if (showTagSuggestions) {
|
||||
setShowTagSuggestions(false);
|
||||
setFilteredTags([]);
|
||||
} else if (showProjectSuggestions) {
|
||||
setShowProjectSuggestions(false);
|
||||
setFilteredProjects([]);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
|
|
@ -376,7 +786,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, showTagSuggestions, handleClose]);
|
||||
}, [isOpen, showTagSuggestions, showProjectSuggestions, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
|
@ -416,7 +826,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
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<InboxModalProps> = ({
|
|||
{/* Tags display like TaskItem */}
|
||||
{inputText &&
|
||||
parseHashtags(inputText).length > 0 && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{parseHashtags(inputText).map(
|
||||
(tagName, index) => {
|
||||
const tag = tags.find(
|
||||
|
|
@ -482,50 +895,116 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
t.name.toLowerCase() ===
|
||||
tagName.toLowerCase()
|
||||
);
|
||||
const isLast =
|
||||
index ===
|
||||
parseHashtags(
|
||||
inputText
|
||||
).length -
|
||||
1;
|
||||
|
||||
if (tag) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
className="hover:underline"
|
||||
onClick={(
|
||||
e
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
{
|
||||
tagName
|
||||
}
|
||||
{tagName}
|
||||
</Link>
|
||||
{!isLast &&
|
||||
', '}
|
||||
<button
|
||||
onClick={() => removeTagFromText(tagName)}
|
||||
className="h-3 w-3 text-blue-400 hover:text-red-500 transition-colors"
|
||||
title="Remove tag"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="text-orange-500 dark:text-orange-400"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
{tagName}
|
||||
{!isLast &&
|
||||
', '}
|
||||
<button
|
||||
onClick={() => removeTagFromText(tagName)}
|
||||
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
|
||||
title="Remove tag"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects display like TaskItem */}
|
||||
{inputText &&
|
||||
parseProjectRefs(inputText).length > 0 && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{parseProjectRefs(inputText).map(
|
||||
(projectName, index) => {
|
||||
const project = projects.find(
|
||||
(p) =>
|
||||
p.name.toLowerCase() ===
|
||||
projectName.toLowerCase()
|
||||
);
|
||||
|
||||
if (project) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 dark:bg-green-900/20 rounded text-green-600 dark:text-green-400"
|
||||
>
|
||||
<Link
|
||||
to={`/projects?project=${encodeURIComponent(project.name)}`}
|
||||
className="hover:underline"
|
||||
onClick={(
|
||||
e
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
{projectName}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => removeProjectFromText(projectName)}
|
||||
className="h-3 w-3 text-green-400 hover:text-red-500 transition-colors"
|
||||
title="Remove project"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
{projectName}
|
||||
<button
|
||||
onClick={() => removeProjectFromText(projectName)}
|
||||
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
|
||||
title="Remove project"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -556,6 +1035,34 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Suggestions Dropdown */}
|
||||
{showProjectSuggestions &&
|
||||
filteredProjects.length > 0 && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
|
||||
style={{
|
||||
left: `${dropdownPosition.left}px`,
|
||||
top: `${dropdownPosition.top + 4}px`,
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{filteredProjects.map((project, index) => (
|
||||
<button
|
||||
key={project.id || index}
|
||||
onClick={() =>
|
||||
handleProjectSelect(
|
||||
project.name
|
||||
)
|
||||
}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
+{project.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -41,11 +41,10 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
onCreateProject,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Note>({
|
||||
id: note?.id,
|
||||
title: note?.title || '',
|
||||
content: note?.content || '',
|
||||
tags: note?.tags || [],
|
||||
const [formData, setFormData] = useState<Note>(note || {
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
});
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
note?.tags?.map((tag) => tag.name) || []
|
||||
|
|
@ -97,24 +96,32 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
|
||||
// Initialize form data when modal opens - exactly like TaskModal
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Initialize form data
|
||||
if (isOpen && note) {
|
||||
// Initialize form data directly from note (like TaskModal)
|
||||
setFormData(note);
|
||||
const tagNames = note?.tags?.map((tag) => tag.name) || [];
|
||||
setFormData({
|
||||
id: note?.id,
|
||||
title: note?.title || '',
|
||||
content: note?.content || '',
|
||||
tags: note?.tags || [],
|
||||
});
|
||||
setTags(tagNames);
|
||||
setError(null);
|
||||
|
||||
// Initialize project name from note - exactly like TaskModal
|
||||
const projectIdToFind = note?.project?.id || note?.Project?.id || note?.project_id;
|
||||
|
||||
const currentProject = memoizedProjects.find(
|
||||
(project) =>
|
||||
project.id === (note?.project?.id || note?.Project?.id)
|
||||
(project) => project.id === projectIdToFind
|
||||
);
|
||||
setNewProjectName(currentProject ? currentProject.name : '');
|
||||
|
||||
// Auto-expand sections if they have content from inbox item
|
||||
const shouldExpandTags = tagNames.length > 0;
|
||||
const shouldExpandProject = !!currentProject;
|
||||
|
||||
if (shouldExpandTags || shouldExpandProject) {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
tags: shouldExpandTags,
|
||||
project: shouldExpandProject,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [isOpen, note, memoizedProjects]);
|
||||
|
||||
|
|
@ -123,6 +130,11 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
// Reset expanded sections when closing
|
||||
setExpandedSections({
|
||||
tags: false,
|
||||
project: false,
|
||||
});
|
||||
}, 300);
|
||||
}, [onClose]);
|
||||
|
||||
|
|
|
|||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -27,7 +27,6 @@
|
|||
"recharts": "^2.15.4",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"swr": "^2.2.5",
|
||||
"tagify": "^0.1.1",
|
||||
"typescript-eslint": "^8.36.0",
|
||||
|
|
@ -62,6 +61,7 @@
|
|||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-jest": "^29.0.0",
|
||||
|
|
@ -2184,6 +2184,7 @@
|
|||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -2193,6 +2194,7 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
|
|
@ -2205,6 +2207,7 @@
|
|||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
|
|
@ -2222,6 +2225,7 @@
|
|||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -2234,6 +2238,7 @@
|
|||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -2246,12 +2251,14 @@
|
|||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
|
|
@ -2269,6 +2276,7 @@
|
|||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
|
|
@ -2284,6 +2292,7 @@
|
|||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
|
|
@ -6550,6 +6559,7 @@
|
|||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
|
|
@ -6598,6 +6608,7 @@
|
|||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emojis-list": {
|
||||
|
|
@ -7839,6 +7850,7 @@
|
|||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
|
|
@ -7855,6 +7867,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
|
@ -9178,6 +9191,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -12291,6 +12305,7 @@
|
|||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
|
|
@ -12820,6 +12835,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
|
|
@ -14312,6 +14328,7 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
|
||||
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^11.0.0",
|
||||
|
|
@ -14331,6 +14348,7 @@
|
|||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
|
||||
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.3.1",
|
||||
|
|
@ -14354,6 +14372,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
|
|
@ -14369,6 +14388,7 @@
|
|||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -14378,6 +14398,7 @@
|
|||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
|
||||
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
|
|
@ -14393,6 +14414,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
|
|
@ -15143,6 +15165,7 @@
|
|||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
|
|
@ -15158,6 +15181,7 @@
|
|||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
|
|
@ -15292,6 +15316,7 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
|
@ -17127,6 +17152,7 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-jest": "^29.0.0",
|
||||
|
|
@ -79,7 +80,6 @@
|
|||
"recharts": "^2.15.4",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"swr": "^2.2.5",
|
||||
"tagify": "^0.1.1",
|
||||
"typescript-eslint": "^8.36.0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue