tududi/frontend/components/Inbox/QuickCaptureInput.tsx
Chris dcb711c515
fix(inbox): Fix tag/project autocomplete selection (#1043)
* fix(mcp): Include subtasks in get_task API response

Add Subtasks association to the findTaskByIdentifier function
so that the get_task MCP API endpoint returns subtasks along
with the main task. This enables clients to access the full
task hierarchy in a single API call.

The serializeTask function already supported subtasks
serialization, so this change only required updating the
query includes to load the Subtasks relation with proper
ordering and Tag associations.

Fixes #1029

* fix(inbox): Fix tag/project autocomplete selection

Fixes #996

Previously, when creating a task from inbox with autocomplete suggestions,
the tag/project replacement would fail if there was regular text before
the hashtag or plus sign. This caused two issues:

1. When typing "#technical_writing" and creating a task, the tag wouldn't
   be created or applied because the autocomplete wasn't replacing the input
2. When typing "#tech_" and selecting "technical_writing" from autocomplete,
   a new tag "tech_" would be created instead of applying the existing tag

This was caused by an overly restrictive condition in handleTagSelect and
handleProjectSelect that prevented replacement when there was regular text
before the tag/project marker.

Changes:
- Removed the allowReplacement condition that blocked autocomplete when
  regular text preceded the tag/project marker
- Simplified handleTagSelect and handleProjectSelect to always replace
  partial input when a suggestion is selected
- Added a space after the selected tag/project for better UX
2026-04-18 10:04:57 +03:00

2131 lines
100 KiB
TypeScript

import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
useImperativeHandle,
} from 'react';
import { Task } from '../../entities/Task';
import { Tag } from '../../entities/Tag';
import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note';
import { useToast } from '../Shared/ToastContext';
import { useTranslation } from 'react-i18next';
import { createInboxItemWithStore } from '../../utils/inboxService';
import { isAuthError } from '../../utils/authUtils';
import { createTag } from '../../utils/tagsService';
import { createProject } from '../../utils/projectsService';
import {
ClipboardDocumentListIcon,
DocumentTextIcon,
FolderIcon,
PlusIcon,
LightBulbIcon,
LinkIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { useStore } from '../../store/useStore';
import { isUrl, extractUrlTitle } from '../../utils/urlService';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
import InboxSelectedChips from './InboxSelectedChips';
import SuggestionsDropdown from './SuggestionsDropdown';
import InboxCard from './InboxCard';
export interface QuickCaptureInputHandle {
submit: (forceInbox?: boolean) => Promise<void>;
}
export interface InboxComposerFooterContext {
text: string;
cleanedText: string;
hashtags: string[];
projectRefs: string[];
clearText: () => void;
}
interface QuickCaptureInputProps {
onTaskCreate?: (task: Task) => Promise<void>;
onNoteCreate?: (note: Note) => Promise<void>;
projects?: Project[];
autoFocus?: boolean;
mode?: 'create' | 'edit';
initialValue?: string;
hidePrimaryButton?: boolean;
onSubmitOverride?: (text: string) => Promise<void>;
onAfterSubmit?: () => void;
renderFooterActions?: (
context: InboxComposerFooterContext
) => React.ReactNode;
openTaskModal?: (task: Task, inboxItemUid?: string) => void;
openProjectModal?: (project: Project | null, inboxItemUid?: string) => void;
openNoteModal?: (note: Note | null, inboxItemUid?: string) => void;
cardClassName?: string;
multiline?: boolean;
}
interface UrlPreviewState {
detectedText: string;
url: string;
title: string | null;
description: string | null;
image: string | null;
isLoading: boolean;
error?: string | null;
}
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/i;
// Simplified regex to avoid catastrophic backtracking with nested quantifiers
const urlWithoutProtocolRegex =
/(?:^|\s)((?:www\.)?[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::[0-9]+)?(?:\/[^\s]*)?)/i;
const normalizeUrl = (value: string) => {
if (!value) {
return '';
}
if (/^https?:\/\//i.test(value)) {
return value;
}
return `https://${value}`;
};
const extractFirstUrlFromText = (text: string): string | null => {
if (!text) {
return null;
}
const withProtocolMatch = text.match(urlWithProtocolRegex);
if (withProtocolMatch && withProtocolMatch[0]) {
return withProtocolMatch[0];
}
const withoutProtocolMatch = text.match(urlWithoutProtocolRegex);
if (withoutProtocolMatch && withoutProtocolMatch[1]) {
return withoutProtocolMatch[1];
}
return null;
};
const QuickCaptureInput = React.forwardRef<
QuickCaptureInputHandle,
QuickCaptureInputProps
>(
(
{
onTaskCreate,
onNoteCreate,
projects: propProjects = [],
autoFocus = false,
mode = 'create',
initialValue = '',
hidePrimaryButton = false,
onSubmitOverride,
onAfterSubmit,
renderFooterActions,
openTaskModal,
openProjectModal,
openNoteModal,
cardClassName,
multiline = false,
},
ref
) => {
const { t } = useTranslation();
const [inputText, setInputText] = useState<string>(initialValue);
const [isSaving, setIsSaving] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
const { tagsStore } = useStore();
const { setTags, refreshTags } = tagsStore;
const tags = tagsStore.getTags();
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
const [showProjectSuggestions, setShowProjectSuggestions] =
useState(false);
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const projects = propProjects;
const [cursorPosition, setCursorPosition] = useState(0);
const [, setCurrentHashtagQuery] = useState('');
const [, setCurrentProjectQuery] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({
left: 0,
top: 0,
});
const [selectedSuggestionIndex, setSelectedSuggestionIndex] =
useState(-1);
const [analysisResult, setAnalysisResult] = useState<{
parsed_tags: string[];
parsed_projects: string[];
cleaned_content: string;
suggested_type: 'task' | 'note' | null;
suggested_reason: string | null;
} | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const analysisTimeoutRef = useRef<NodeJS.Timeout>();
const analysisRequestIdRef = useRef(0);
const [urlPreview, setUrlPreview] = useState<UrlPreviewState | null>(
null
);
const [urlPreviewImageError, setUrlPreviewImageError] = useState(false);
const urlPreviewRequestIdRef = useRef(0);
const dismissedPreviewUrlRef = useRef<string | null>(null);
const isEditMode = mode === 'edit';
useEffect(() => {
if (isEditMode) {
setInputText(initialValue || '');
}
}, [initialValue, isEditMode]);
useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus();
}
}, [autoFocus]);
const clearComposerText = useCallback(() => {
setInputText('');
setAnalysisResult(null);
setUrlPreview(null);
dismissedPreviewUrlRef.current = null;
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const parseHashtags = (text: string): string[] => {
const trimmedText = text.trim();
const matches: string[] = [];
const words = trimmedText.split(/\s+/);
if (words.length === 0) return matches;
let i = 0;
while (i < words.length) {
if (words[i].startsWith('#') || words[i].startsWith('+')) {
let groupEnd = i;
while (
groupEnd < words.length &&
(words[groupEnd].startsWith('#') ||
words[groupEnd].startsWith('+'))
) {
groupEnd++;
}
for (let j = i; j < groupEnd; j++) {
if (words[j].startsWith('#')) {
const tagName = words[j].substring(1);
if (
tagName &&
/^[a-zA-Z0-9_-]+$/.test(tagName) &&
!matches.includes(tagName)
) {
matches.push(tagName);
}
}
}
i = groupEnd;
} else {
i++;
}
}
return matches;
};
const parseProjectRefs = (text: string): string[] => {
const trimmedText = text.trim();
const matches: string[] = [];
const tokens = tokenizeText(trimmedText);
let i = 0;
while (i < tokens.length) {
if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) {
let groupEnd = i;
while (
groupEnd < tokens.length &&
(tokens[groupEnd].startsWith('#') ||
tokens[groupEnd].startsWith('+'))
) {
groupEnd++;
}
for (let j = i; j < groupEnd; j++) {
if (tokens[j].startsWith('+')) {
let projectName = tokens[j].substring(1);
if (
projectName.startsWith('"') &&
projectName.endsWith('"')
) {
projectName = projectName.slice(1, -1);
}
if (projectName && !matches.includes(projectName)) {
matches.push(projectName);
}
}
}
i = groupEnd;
} else {
i++;
}
}
return matches;
};
const fetchUrlPreview = useCallback(
async (rawUrl: string, detectedText: string) => {
const normalizedUrl = normalizeUrl(rawUrl);
urlPreviewRequestIdRef.current += 1;
const currentRequestId = urlPreviewRequestIdRef.current;
dismissedPreviewUrlRef.current = null;
setUrlPreviewImageError(false);
setUrlPreview({
detectedText,
url: normalizedUrl,
title: null,
description: null,
image: null,
isLoading: true,
error: null,
});
const result = await extractUrlTitle(normalizedUrl);
if (currentRequestId !== urlPreviewRequestIdRef.current) {
return;
}
setUrlPreview((prev) => {
if (!prev || prev.url !== normalizedUrl) {
return prev;
}
return {
...prev,
title: result.title ?? null,
description: result.description ?? null,
image: result.image ?? null,
isLoading: false,
error: result.error ?? null,
};
});
},
[]
);
useEffect(() => {
const detectedUrl = extractFirstUrlFromText(inputText);
if (!detectedUrl) {
if (urlPreview) {
setUrlPreview(null);
}
dismissedPreviewUrlRef.current = null;
urlPreviewRequestIdRef.current += 1;
return;
}
const normalized = normalizeUrl(detectedUrl);
if (dismissedPreviewUrlRef.current === normalized) {
return;
}
if (urlPreview && urlPreview.url === normalized) {
return;
}
fetchUrlPreview(detectedUrl, detectedUrl);
}, [inputText, fetchUrlPreview, urlPreview]);
useEffect(() => {
if (!urlPreview) {
setUrlPreviewImageError(false);
return;
}
setUrlPreviewImageError(false);
}, [urlPreview?.url]);
const tokenizeText = (text: string): string[] => {
const tokens: string[] = [];
let currentToken = '';
let inQuotes = false;
let i = 0;
while (i < text.length) {
const char = text[i];
if (char === '"' && (i === 0 || text[i - 1] === '+')) {
inQuotes = true;
currentToken += char;
} else if (char === '"' && inQuotes) {
inQuotes = false;
currentToken += char;
} else if (char === ' ' && !inQuotes) {
if (currentToken) {
tokens.push(currentToken);
currentToken = '';
}
} else {
currentToken += char;
}
i++;
}
if (currentToken) {
tokens.push(currentToken);
}
return tokens;
};
const getCurrentHashtagQuery = (
text: string,
position: number
): string => {
const beforeCursor = text.substring(0, position);
const afterCursor = text.substring(position);
const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/);
if (!hashtagMatch) return '';
const hashtagStart = beforeCursor.lastIndexOf('#');
const textBeforeHashtag = text.substring(0, hashtagStart).trim();
const textAfterCursor = afterCursor.trim();
if (textAfterCursor === '') {
return hashtagMatch[1];
}
if (textBeforeHashtag === '') {
return hashtagMatch[1];
}
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 '';
};
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 '';
const projectQuery = projectMatch[1] || projectMatch[2] || '';
const projectStart = beforeCursor.lastIndexOf('+');
const textBeforeProject = text.substring(0, projectStart).trim();
const textAfterCursor = afterCursor.trim();
if (textAfterCursor === '') {
return projectQuery;
}
if (textBeforeProject === '') {
return projectQuery;
}
const wordsBeforeProject = textBeforeProject
.split(/\s+/)
.filter((word) => word.length > 0);
const allWordsAreTagsOrProjects = wordsBeforeProject.every(
(word) => word.startsWith('#') || word.startsWith('+')
);
if (allWordsAreTagsOrProjects) {
return projectQuery;
}
return '';
};
const escapeRegExp = (value: string) =>
value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const cleanInputSpacing = (text: string) =>
text
.replace(/\s{2,}/g, ' ')
.replace(/\s+\n/g, '\n')
.replace(/\n\s+/g, '\n')
.trim();
const removeTagFromText = (tagToRemove: string) => {
const escaped = escapeRegExp(tagToRemove);
const pattern = new RegExp(`(^|\\s)#${escaped}(?=$|\\s)`, 'gi');
const updated = cleanInputSpacing(
inputText.replace(pattern, (_, prefix) => prefix ?? '')
);
setInputText(updated);
setAnalysisResult(null);
if (inputRef.current) {
inputRef.current.focus();
}
};
const removeProjectFromText = (projectToRemove: string) => {
const escaped = escapeRegExp(projectToRemove);
const quotedPattern = new RegExp(
`(^|\\s)\\+"${escaped}"(?=$|\\s)`,
'gi'
);
const simplePattern = new RegExp(
`(^|\\s)\\+${escaped}(?=$|\\s)`,
'gi'
);
const updated = cleanInputSpacing(
inputText
.replace(quotedPattern, (_, prefix) => prefix ?? '')
.replace(simplePattern, (_, prefix) => prefix ?? '')
);
setInputText(updated);
setAnalysisResult(null);
if (inputRef.current) {
inputRef.current.focus();
}
};
const calculateDropdownPosition = (
input: HTMLInputElement | HTMLTextAreaElement,
cursorPos: number
) => {
const temp = document.createElement('span');
temp.style.visibility = 'hidden';
temp.style.position = 'absolute';
temp.style.fontSize = getComputedStyle(input).fontSize;
temp.style.fontFamily = getComputedStyle(input).fontFamily;
temp.style.fontWeight = getComputedStyle(input).fontWeight;
temp.textContent = inputText.substring(0, cursorPos);
document.body.appendChild(temp);
const textWidth = temp.getBoundingClientRect().width;
document.body.removeChild(temp);
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();
let showDropdown = false;
if (textAfterCursor === '' || textBeforeHashtag === '') {
showDropdown = true;
} else {
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) {
const tempToHashtag = document.createElement('span');
tempToHashtag.style.visibility = 'hidden';
tempToHashtag.style.position = 'absolute';
tempToHashtag.style.fontSize =
getComputedStyle(input).fontSize;
tempToHashtag.style.fontFamily =
getComputedStyle(input).fontFamily;
tempToHashtag.style.fontWeight =
getComputedStyle(input).fontWeight;
tempToHashtag.textContent = inputText.substring(
0,
hashtagStart
);
document.body.appendChild(tempToHashtag);
const hashtagOffset =
tempToHashtag.getBoundingClientRect().width;
document.body.removeChild(tempToHashtag);
return {
left: hashtagOffset,
top: input.offsetHeight,
};
}
}
if (projectMatch) {
const projectStart = beforeCursor.lastIndexOf('+');
const textBeforeProject = inputText
.substring(0, projectStart)
.trim();
const textAfterCursor = afterCursor.trim();
let showDropdown = false;
if (textAfterCursor === '' || textBeforeProject === '') {
showDropdown = true;
} else {
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) {
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 };
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const newText = e.target.value;
const newCursorPosition = e.target.selectionStart || 0;
setInputText(newText);
setCursorPosition(newCursorPosition);
const hashtagQuery = getCurrentHashtagQuery(
newText,
newCursorPosition
);
setCurrentHashtagQuery(hashtagQuery);
const projectQuery = getCurrentProjectQuery(
newText,
newCursorPosition
);
setCurrentProjectQuery(projectQuery);
if (
(newText.charAt(newCursorPosition - 1) === '#' ||
hashtagQuery) &&
hashtagQuery !== ''
) {
setShowProjectSuggestions(false);
setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
const filtered = tags
.filter((tag) =>
tag.name
.toLowerCase()
.startsWith(hashtagQuery.toLowerCase())
)
.slice(0, 5);
const position = calculateDropdownPosition(
e.target,
newCursorPosition
);
setDropdownPosition(position);
setFilteredTags(filtered);
setShowTagSuggestions(true);
setSelectedSuggestionIndex(-1);
} else if (
(newText.charAt(newCursorPosition - 1) === '+' ||
projectQuery) &&
projectQuery !== ''
) {
setShowTagSuggestions(false);
setFilteredTags([]);
setSelectedSuggestionIndex(-1);
const filtered = projects
.filter((project) =>
project.name
.toLowerCase()
.includes(projectQuery.toLowerCase())
)
.slice(0, 5);
const position = calculateDropdownPosition(
e.target,
newCursorPosition
);
setDropdownPosition(position);
setFilteredProjects(filtered);
setShowProjectSuggestions(true);
setSelectedSuggestionIndex(-1);
} else {
setShowTagSuggestions(false);
setFilteredTags([]);
setShowProjectSuggestions(false);
setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
}
};
const getAllTags = (text: string): string[] => {
if (analysisResult) {
const explicitTags = analysisResult.parsed_tags;
const isUrlContent =
isUrl(text.trim()) ||
analysisResult.suggested_reason === 'url_detected';
if (isUrlContent) {
const hasBookmarkTag = explicitTags.some(
(tag) => tag.toLowerCase() === 'bookmark'
);
if (!hasBookmarkTag) {
return [...explicitTags, 'bookmark'];
}
}
return explicitTags;
}
const explicitTags = parseHashtags(text);
if (isUrl(text.trim())) {
const hasBookmarkTag = explicitTags.some(
(tag) => tag.toLowerCase() === 'bookmark'
);
if (!hasBookmarkTag) {
return [...explicitTags, 'bookmark'];
}
}
return explicitTags;
};
const getAllProjects = (text: string): string[] => {
if (analysisResult) {
return analysisResult.parsed_projects;
}
return parseProjectRefs(text);
};
const getCleanedContent = (text: string): string => {
if (analysisResult) {
return analysisResult.cleaned_content;
}
return text
.replace(/#[a-zA-Z0-9_-]+/g, '')
.replace(/\+\S+/g, '')
.trim();
};
const buildTagObjects = (hashtagNames: string[]) => {
return hashtagNames.map((hashtagName) => {
const existingTag = tags.find(
(tag) =>
tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName };
});
};
const resolveProjectUid = (projectRefsList: string[]) => {
if (projectRefsList.length === 0) {
return undefined;
}
const projectName = projectRefsList[0];
const matchingProject = projects.find(
(project) =>
project.name.toLowerCase() === projectName.toLowerCase()
);
return matchingProject ? matchingProject.uid : undefined;
};
const getSuggestion = (): {
type: 'note' | 'task' | null;
message: string | null;
projectName: string | null;
} => {
if (!analysisResult || !analysisResult.suggested_type) {
return { type: null, message: null, projectName: null };
}
const projectName = analysisResult.parsed_projects[0] || null;
const type = analysisResult.suggested_type;
if (type === 'note') {
const isUrlNote =
analysisResult.suggested_reason === 'url_detected';
const message = isUrlNote
? `Will be saved as a bookmark note${projectName ? ` for ${projectName}` : ''}.`
: `Will be saved as a note${projectName ? ` for ${projectName}` : ''}.`;
return {
type: 'note',
message,
projectName,
};
} else if (type === 'task') {
return {
type: 'task',
message: `Will be created as a task${projectName ? ` under ${projectName}` : ''}.`,
projectName,
};
}
return { type: null, message: null, projectName: null };
};
const analyzeText = useCallback(
async (text: string, requestId: number) => {
if (!text.trim()) {
if (analysisRequestIdRef.current === requestId) {
setAnalysisResult(null);
setIsAnalyzing(false);
}
return;
}
try {
if (analysisRequestIdRef.current === requestId) {
setIsAnalyzing(true);
}
const token = await getCsrfToken();
const response = await fetch(
getApiPath('inbox/analyze-text'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': token,
},
credentials: 'include',
body: JSON.stringify({ content: text }),
}
);
if (analysisRequestIdRef.current !== requestId) {
return;
}
if (response.ok) {
const result = await response.json();
setAnalysisResult(result);
} else {
console.error(
'Failed to analyze text:',
response.statusText
);
setAnalysisResult(null);
}
} catch (error) {
if (analysisRequestIdRef.current !== requestId) {
return;
}
console.error('Error analyzing text:', error);
setAnalysisResult(null);
} finally {
if (analysisRequestIdRef.current === requestId) {
setIsAnalyzing(false);
}
}
},
[]
);
useEffect(() => {
if (analysisTimeoutRef.current) {
clearTimeout(analysisTimeoutRef.current);
}
const requestId = analysisRequestIdRef.current + 1;
analysisRequestIdRef.current = requestId;
const textForAnalysis = inputText;
analysisTimeoutRef.current = setTimeout(() => {
analyzeText(textForAnalysis, requestId);
}, 300);
return () => {
if (analysisTimeoutRef.current) {
clearTimeout(analysisTimeoutRef.current);
}
};
}, [inputText, analyzeText]);
const handleTagSelect = (tagName: string) => {
const beforeCursor = inputText.substring(0, cursorPosition);
const afterCursor = inputText.substring(cursorPosition);
const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/);
if (hashtagMatch) {
const newText =
beforeCursor.replace(
/#([a-zA-Z0-9_]*)$/,
`#${tagName} `
) + afterCursor;
setInputText(newText);
setShowTagSuggestions(false);
setFilteredTags([]);
setSelectedSuggestionIndex(-1);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
const newCursorPos = beforeCursor.replace(
/#([a-zA-Z0-9_]*)$/,
`#${tagName} `
).length;
inputRef.current.setSelectionRange(
newCursorPos,
newCursorPos
);
}
}, 0);
}
};
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 formattedProjectName = projectName.includes(' ')
? `"${projectName}"`
: projectName;
const newText =
beforeCursor.replace(
/\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/,
`+${formattedProjectName} `
) + afterCursor;
setInputText(newText);
setShowProjectSuggestions(false);
setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
const newCursorPos = beforeCursor.replace(
/\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/,
`+${formattedProjectName} `
).length;
inputRef.current.setSelectionRange(
newCursorPos,
newCursorPos
);
}
}, 0);
}
};
const createMissingTags = async (text: string): Promise<void> => {
const hashtagsInText = getAllTags(text);
const currentTags = tagsStore.getTags();
const existingTagNames = currentTags.map((tag) =>
tag.name.toLowerCase()
);
const missingTags = hashtagsInText.filter(
(tagName) => !existingTagNames.includes(tagName.toLowerCase())
);
let createdNewTag = false;
for (const tagName of missingTags) {
try {
const newTag = await createTag({ name: tagName });
setTags([...tagsStore.getTags(), newTag]);
createdNewTag = true;
} catch (error) {
console.error(`Failed to create tag "${tagName}":`, error);
}
}
if (createdNewTag && typeof refreshTags === 'function') {
try {
await refreshTags();
} catch (error) {
console.error(
'Failed to refresh tags after creation:',
error
);
}
}
};
const createMissingProjects = async (text: string): Promise<void> => {
const projectsInText = getAllProjects(text);
const existingProjectNames = projects.map((project) =>
project.name.toLowerCase()
);
const missingProjects = projectsInText.filter(
(projectName) =>
!existingProjectNames.includes(projectName.toLowerCase())
);
for (const projectName of missingProjects) {
try {
await createProject({
name: projectName,
status: 'planned',
});
} catch (error) {
console.error(
`Failed to create project "${projectName}":`,
error
);
}
}
};
const handleSubmit = useCallback(
async (forceInbox = false) => {
const trimmedText = inputText.trim();
if ((!trimmedText && !isEditMode) || isSaving) return;
setIsSaving(true);
try {
if (onSubmitOverride) {
await createMissingTags(trimmedText);
await createMissingProjects(trimmedText);
await onSubmitOverride(trimmedText);
onAfterSubmit?.();
setIsSaving(false);
return;
}
if (
analysisResult?.suggested_type === 'task' &&
!forceInbox &&
onTaskCreate
) {
await createMissingTags(trimmedText);
await createMissingProjects(trimmedText);
const cleanedText = getCleanedContent(trimmedText);
const taskTags = analysisResult.parsed_tags.map(
(tagName) => {
const existingTag = tags.find(
(tag) =>
tag.name.toLowerCase() ===
tagName.toLowerCase()
);
return existingTag || { name: tagName };
}
);
let projectId = undefined;
if (analysisResult.parsed_projects.length > 0) {
const projectName =
analysisResult.parsed_projects[0];
const matchingProject = projects.find(
(project) =>
project.name.toLowerCase() ===
projectName.toLowerCase()
);
if (matchingProject) {
projectId = matchingProject.id;
}
}
const newTask: Task = {
name: cleanedText,
status: 'not_started',
priority: 'low',
tags: taskTags,
project_id: projectId,
completed_at: null,
};
try {
await onTaskCreate(newTask);
showSuccessToast(t('task.createSuccess'));
setInputText('');
setAnalysisResult(null);
if (inputRef.current) {
inputRef.current.focus();
}
return;
} catch (error: any) {
if (isAuthError(error)) {
return;
}
throw error;
}
}
if (
analysisResult?.suggested_type === 'note' &&
!forceInbox &&
onNoteCreate
) {
await createMissingTags(trimmedText);
await createMissingProjects(trimmedText);
const cleanedText = getCleanedContent(trimmedText);
const hashtagTags = analysisResult.parsed_tags.map(
(tagName) => {
const existingTag = tags.find(
(tag) =>
tag.name.toLowerCase() ===
tagName.toLowerCase()
);
return existingTag || { name: tagName };
}
);
const isUrlContent =
isUrl(trimmedText) ||
analysisResult.suggested_reason === 'url_detected';
const bookmarkTag = isUrlContent
? [{ name: 'bookmark' }]
: [];
const hasBookmarkInParsed = hashtagTags.some(
(tag) => tag.name.toLowerCase() === 'bookmark'
);
const finalBookmarkTag = hasBookmarkInParsed
? []
: bookmarkTag;
const taskTags = [...hashtagTags, ...finalBookmarkTag];
let projectId = undefined;
if (analysisResult.parsed_projects.length > 0) {
const projectName =
analysisResult.parsed_projects[0];
const matchingProject = projects.find(
(project) =>
project.name.toLowerCase() ===
projectName.toLowerCase()
);
if (matchingProject) {
projectId = matchingProject.id;
}
}
const newNote: Note = {
title: cleanedText || trimmedText,
content: trimmedText,
tags: taskTags,
project_id: projectId,
};
try {
await onNoteCreate(newNote);
showSuccessToast(
t(
'note.createSuccess',
'Note created successfully'
)
);
setInputText('');
setAnalysisResult(null);
if (inputRef.current) {
inputRef.current.focus();
}
return;
} catch (error: any) {
console.error(
'Error in note creation flow:',
error
);
if (isAuthError(error)) {
return;
}
throw error;
}
}
try {
await createMissingTags(trimmedText);
await createMissingProjects(trimmedText);
await createInboxItemWithStore(trimmedText);
showSuccessToast(t('inbox.itemAdded'));
setInputText('');
setAnalysisResult(null);
if (inputRef.current) {
inputRef.current.focus();
}
} catch (error) {
console.error('Failed to create inbox item:', error);
showErrorToast(t('inbox.addError'));
}
} catch (error) {
console.error('Failed to save:', error);
showErrorToast(t('inbox.addError'));
} finally {
setIsSaving(false);
}
},
[
inputText,
isSaving,
onTaskCreate,
onNoteCreate,
showSuccessToast,
showErrorToast,
t,
tags,
setTags,
analysisResult,
createMissingTags,
createMissingProjects,
getCleanedContent,
projects,
onSubmitOverride,
onAfterSubmit,
]
);
useImperativeHandle(
ref,
() => ({
submit: (forceInbox = false) => handleSubmit(forceInbox),
}),
[handleSubmit]
);
const composerFooterContext = useMemo<InboxComposerFooterContext>(
() => ({
text: inputText,
cleanedText: getCleanedContent(inputText.trim()),
hashtags: getAllTags(inputText),
projectRefs: getAllProjects(inputText),
clearText: clearComposerText,
updateText: (value: string) => setInputText(value),
}),
[inputText, clearComposerText]
);
const defaultFooterActions =
!renderFooterActions &&
!isEditMode &&
(openTaskModal || openProjectModal || openNoteModal) ? (
<div className="pt-3 mt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{openTaskModal && (
<button
onClick={() => {
const taskTags = buildTagObjects(
composerFooterContext.hashtags
);
const projectUid = resolveProjectUid(
composerFooterContext.projectRefs
);
const cleaned =
composerFooterContext.cleanedText ||
composerFooterContext.text.trim();
if (!cleaned) {
return;
}
const newTask: Task = {
name: cleaned,
status: 'not_started',
priority: null,
tags: taskTags,
Project: projectUid
? ({
uid: projectUid,
} as Project)
: undefined,
completed_at: null,
};
void openTaskModal(newTask);
composerFooterContext.clearText();
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-200 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-200 dark:focus:ring-offset-gray-900"
>
<span className="flex items-center gap-1">
<span className="sm:hidden text-sm font-semibold leading-none">
+
</span>
<ClipboardDocumentListIcon className="h-4 w-4" />
<span className="hidden sm:inline">
{t('inbox.createTask', 'Task')}
</span>
</span>
</button>
)}
{openNoteModal && (
<button
onClick={() => {
const hashtagTags = buildTagObjects(
composerFooterContext.hashtags
);
const bookmarkTag =
composerFooterContext.hashtags.some(
(tag) =>
tag.toLowerCase() ===
'bookmark'
)
? []
: isUrl(
composerFooterContext.text.trim()
)
? [{ name: 'bookmark' }]
: [];
const noteTags = [
...hashtagTags,
...bookmarkTag,
];
const projectUid = resolveProjectUid(
composerFooterContext.projectRefs
);
const newNote: Note = {
title:
composerFooterContext.cleanedText ||
composerFooterContext.text.trim(),
content:
composerFooterContext.text.trim(),
tags: noteTags,
project_uid: projectUid,
};
openNoteModal(newNote);
composerFooterContext.clearText();
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 dark:text-purple-200 bg-purple-50 dark:bg-purple-900/20 border border-purple-100 dark:border-purple-800 rounded-md hover:bg-purple-100 dark:hover:bg-purple-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-200 dark:focus:ring-offset-gray-900"
>
<span className="flex items-center gap-1">
<span className="sm:hidden text-sm font-semibold leading-none">
+
</span>
<DocumentTextIcon className="h-4 w-4" />
<span className="hidden sm:inline">
{t('inbox.createNote', 'Note')}
</span>
</span>
</button>
)}
{openProjectModal && (
<button
onClick={() => {
const cleaned =
composerFooterContext.cleanedText ||
composerFooterContext.text.trim();
if (!cleaned) {
return;
}
const newProject: Project = {
name: cleaned,
description: '',
status: 'planned',
tags: buildTagObjects(
composerFooterContext.hashtags
),
};
openProjectModal(newProject);
composerFooterContext.clearText();
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-200 bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800 rounded-md hover:bg-green-100 dark:hover:bg-green-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-200 dark:focus:ring-offset-gray-900"
>
<span className="flex items-center gap-1">
<span className="sm:hidden text-sm font-semibold leading-none">
+
</span>
<FolderIcon className="h-4 w-4" />
<span className="hidden sm:inline">
{t(
'inbox.createProject',
'Project'
)}
</span>
</span>
</button>
)}
</div>
</div>
</div>
) : null;
const footerActions =
renderFooterActions?.(composerFooterContext) ||
defaultFooterActions;
const shouldShowPrimaryButton = !hidePrimaryButton && !isEditMode;
const cardClasses = cardClassName ?? 'mb-6';
return (
<InboxCard
className={`w-full border border-blue-300 dark:border-blue-600 ${cardClasses}`}
>
<div className="px-4 py-3">
<div
className={`flex flex-row gap-3 ${shouldShowPrimaryButton ? 'items-start justify-between' : 'items-start'}`}
>
<div className="relative flex-1">
<div className="flex items-center gap-3">
<LightBulbIcon className="h-5 w-5 text-amber-400 dark:text-amber-300" />
{multiline ? (
<textarea
ref={(el) => {
inputRef.current = el;
}}
value={inputText}
rows={6}
onChange={handleChange}
onSelect={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onKeyUp={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onClick={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
className="w-full text-base font-normal bg-transparent text-gray-900 dark:text-gray-100 border-0 focus:outline-none focus:ring-0 px-0 py-2 placeholder-gray-400 dark:placeholder-gray-500 resize-none"
placeholder={t(
'inbox.captureThought',
'Capture a thought...'
)}
onKeyDown={(e) => {
const hasTagSuggestions =
showTagSuggestions &&
filteredTags.length > 0;
const hasProjectSuggestions =
showProjectSuggestions &&
filteredProjects.length > 0;
if (hasTagSuggestions) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev <
filteredTags.length -
1
? prev + 1
: 0
);
return;
} else if (
e.key === 'ArrowUp'
) {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev > 0
? prev - 1
: filteredTags.length -
1
);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
const selectedTag =
selectedSuggestionIndex >=
0
? filteredTags[
selectedSuggestionIndex
]
: filteredTags[0];
handleTagSelect(
selectedTag.name
);
return;
} else if (
e.key === 'Enter' &&
selectedSuggestionIndex >= 0
) {
e.preventDefault();
handleTagSelect(
filteredTags[
selectedSuggestionIndex
].name
);
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowTagSuggestions(
false
);
setFilteredTags([]);
setSelectedSuggestionIndex(
-1
);
return;
}
}
if (hasProjectSuggestions) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev <
filteredProjects.length -
1
? prev + 1
: 0
);
return;
} else if (
e.key === 'ArrowUp'
) {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev > 0
? prev - 1
: filteredProjects.length -
1
);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
const selectedProject =
selectedSuggestionIndex >=
0
? filteredProjects[
selectedSuggestionIndex
]
: filteredProjects[0];
handleProjectSelect(
selectedProject.name
);
return;
} else if (
e.key === 'Enter' &&
selectedSuggestionIndex >= 0
) {
e.preventDefault();
handleProjectSelect(
filteredProjects[
selectedSuggestionIndex
].name
);
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowProjectSuggestions(
false
);
setFilteredProjects([]);
setSelectedSuggestionIndex(
-1
);
return;
}
}
if (
e.key === 'Escape' &&
!hasTagSuggestions &&
!hasProjectSuggestions
) {
e.preventDefault();
if (isEditMode && !isSaving) {
handleSubmit();
}
return;
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!isSaving
) {
if (
hasTagSuggestions ||
hasProjectSuggestions
) {
return;
}
e.preventDefault();
handleSubmit();
}
}}
></textarea>
) : (
<input
ref={(el) => {
inputRef.current = el;
}}
type="text"
data-testid="quick-capture-input"
value={inputText}
onChange={handleChange}
onSelect={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onKeyUp={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onClick={(e) => {
const pos =
e.currentTarget
.selectionStart || 0;
setCursorPosition(pos);
if (
showTagSuggestions ||
showProjectSuggestions
) {
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
className="w-full text-base font-normal bg-transparent text-gray-900 dark:text-gray-100 border-0 focus:outline-none focus:ring-0 px-0 py-2 placeholder-gray-400 dark:placeholder-gray-500"
placeholder={t(
'inbox.captureThought',
'Capture a thought...'
)}
onKeyDown={(e) => {
const hasTagSuggestions =
showTagSuggestions &&
filteredTags.length > 0;
const hasProjectSuggestions =
showProjectSuggestions &&
filteredProjects.length > 0;
if (hasTagSuggestions) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev <
filteredTags.length -
1
? prev + 1
: 0
);
return;
} else if (
e.key === 'ArrowUp'
) {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev > 0
? prev - 1
: filteredTags.length -
1
);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
const selectedTag =
selectedSuggestionIndex >=
0
? filteredTags[
selectedSuggestionIndex
]
: filteredTags[0];
handleTagSelect(
selectedTag.name
);
return;
} else if (
e.key === 'Enter' &&
selectedSuggestionIndex >= 0
) {
e.preventDefault();
handleTagSelect(
filteredTags[
selectedSuggestionIndex
].name
);
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowTagSuggestions(
false
);
setFilteredTags([]);
setSelectedSuggestionIndex(
-1
);
return;
}
}
if (hasProjectSuggestions) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev <
filteredProjects.length -
1
? prev + 1
: 0
);
return;
} else if (
e.key === 'ArrowUp'
) {
e.preventDefault();
setSelectedSuggestionIndex(
(prev) =>
prev > 0
? prev - 1
: filteredProjects.length -
1
);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
const selectedProject =
selectedSuggestionIndex >=
0
? filteredProjects[
selectedSuggestionIndex
]
: filteredProjects[0];
handleProjectSelect(
selectedProject.name
);
return;
} else if (
e.key === 'Enter' &&
selectedSuggestionIndex >= 0
) {
e.preventDefault();
handleProjectSelect(
filteredProjects[
selectedSuggestionIndex
].name
);
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowProjectSuggestions(
false
);
setFilteredProjects([]);
setSelectedSuggestionIndex(
-1
);
return;
}
}
if (
e.key === 'Escape' &&
!hasTagSuggestions &&
!hasProjectSuggestions
) {
e.preventDefault();
if (isEditMode && !isSaving) {
handleSubmit();
}
return;
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!isSaving
) {
if (
hasTagSuggestions ||
hasProjectSuggestions
) {
return;
}
e.preventDefault();
handleSubmit();
}
}}
/>
)}
</div>
<InboxSelectedChips
selectedTags={getAllTags(inputText)}
selectedProjects={getAllProjects(inputText)}
tags={tags}
projects={projects}
onRemoveTag={removeTagFromText}
onRemoveProject={removeProjectFromText}
/>
<SuggestionsDropdown
isVisible={
showTagSuggestions &&
filteredTags.length > 0
}
items={filteredTags}
position={dropdownPosition}
selectedIndex={selectedSuggestionIndex}
onSelect={(tag) => handleTagSelect(tag.name)}
renderLabel={(tag) => <>#{tag.name}</>}
/>
<SuggestionsDropdown
isVisible={
showProjectSuggestions &&
filteredProjects.length > 0
}
items={filteredProjects}
position={dropdownPosition}
selectedIndex={selectedSuggestionIndex}
onSelect={(project) =>
handleProjectSelect(project.name)
}
renderLabel={(project) => <>+{project.name}</>}
/>
{urlPreview && (
<div className="mt-3 rounded-lg border border-blue-100 bg-blue-50/70 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<div className="flex items-start gap-3">
<div className="flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-md bg-blue-100 dark:bg-blue-800">
{urlPreview.isLoading ? (
<svg
className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : urlPreview.image &&
!urlPreviewImageError ? (
<img
src={urlPreview.image}
alt={
urlPreview.title ??
urlPreview.url
}
className="h-full w-full object-cover"
onError={() =>
setUrlPreviewImageError(
true
)
}
/>
) : (
<LinkIcon className="h-6 w-6 text-blue-600 dark:text-blue-300" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{urlPreview.title ||
t(
'inbox.linkPreview',
'Link preview'
)}
</p>
{urlPreview.description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400 break-words">
{
urlPreview.description
}
</p>
)}
</div>
<button
type="button"
onClick={() => {
dismissedPreviewUrlRef.current =
urlPreview.url;
setUrlPreview(null);
}}
className="rounded-md p-1 text-gray-400 transition hover:bg-white/60 hover:text-gray-600 dark:hover:bg-white/10"
aria-label={t(
'common.dismiss',
'Dismiss'
)}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400 break-all">
{urlPreview.url}
</span>
{!urlPreview.isLoading &&
!urlPreview.error && (
<a
href={
urlPreview.url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-700 hover:underline dark:text-blue-300"
>
{t(
'common.open',
'Open'
)}
</a>
)}
{urlPreview.error &&
!urlPreview.isLoading && (
<>
<span className="text-xs text-red-500">
{t(
'inbox.linkPreviewError',
'Could not fetch link details'
)}
</span>
<button
type="button"
onClick={() =>
fetchUrlPreview(
urlPreview.url,
urlPreview.detectedText ||
urlPreview.url
)
}
className="text-xs font-medium text-blue-700 hover:underline dark:text-blue-300"
>
{t(
'common.retry',
'Retry'
)}
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
{(() => {
const suggestion = getSuggestion();
return suggestion.type && suggestion.message ? (
<div className="mt-2 p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-md">
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="text-purple-600 dark:text-purple-400 mr-2 mt-0.5">
<svg
className="h-3 w-3"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2l2.09 6.26L20 10.27l-5.91 2.01L12 18.54l-2.09-6.26L4 10.27l5.91-2.01L12 2z" />
<path d="M8 1l1.18 3.52L12 5.64l-2.82.96L8 10.12l-1.18-3.52L4 5.64l2.82-.96L8 1z" />
<path d="M20 14l.79 2.37L23 17.45l-2.21.75L20 20.57l-.79-2.37L17 17.45l2.21-.75L20 14z" />
</svg>
</div>
<div className="flex-1">
<p className="text-xs text-purple-700 dark:text-purple-300 mb-1">
{suggestion.message}
</p>
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-600 dark:text-gray-400">
or
</span>
<button
onClick={() => {
handleSubmit(
true
);
}}
className="text-purple-600 dark:text-purple-400 hover:underline"
>
save as inbox item
</button>
</div>
</div>
</div>
{isAnalyzing && (
<div className="ml-2 h-3 w-3 border-2 border-purple-600 dark:border-purple-400 border-t-transparent rounded-full animate-spin"></div>
)}
</div>
</div>
) : null;
})()}
</div>
{shouldShowPrimaryButton && (
<button
type="button"
onClick={() => handleSubmit(false)}
disabled={!inputText.trim() || isSaving}
className={`flex-shrink-0 self-start mt-2 inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white rounded-lg shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800 transition-colors ${
inputText.trim() && !isSaving
? 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
: 'bg-blue-400 dark:bg-blue-700 cursor-not-allowed'
}`}
>
{isSaving ? (
<>
<svg
className="animate-spin h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('common.saving')}
</>
) : (
<>
<PlusIcon className="h-3.5 w-3.5" />
{t('common.add', 'Add')}
</>
)}
</button>
)}
</div>
{footerActions}
</div>
</InboxCard>
);
}
);
QuickCaptureInput.displayName = 'QuickCaptureInput';
export default QuickCaptureInput;