Transfer tags and project to modals

This commit is contained in:
Chris Veleris 2025-07-11 16:14:12 +03:00
parent 3375322bea
commit 04d39b07e9
6 changed files with 836 additions and 120 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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
View file

@ -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",

View file

@ -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",