tududi/frontend/components/Inbox/QuickCaptureInput.tsx
Chris eee1bbc013
Fix bug 722 (#737)
* Fix project statuses

* Refactor project states

* Add translations
2025-12-28 07:51:15 +02:00

2175 lines
102 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 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;
const urlWithoutProtocolRegex =
/(?:^|\s)((?:www\.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\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 response = await fetch(
getApiPath('inbox/analyze-text'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
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 hashtagStart = beforeCursor.lastIndexOf('#');
const textBeforeHashtag = inputText
.substring(0, hashtagStart)
.trim();
const textAfterCursor = afterCursor.trim();
let allowReplacement = false;
if (textAfterCursor === '' || textBeforeHashtag === '') {
allowReplacement = true;
} else {
const wordsBeforeHashtag = textBeforeHashtag
.split(/\s+/)
.filter((word) => word.length > 0);
const allWordsAreTagsOrProjects = wordsBeforeHashtag.every(
(word) => word.startsWith('#') || word.startsWith('+')
);
if (allWordsAreTagsOrProjects) {
allowReplacement = true;
}
}
if (allowReplacement) {
const newText =
beforeCursor.replace(
/#([a-zA-Z0-9_]*)$/,
`#${tagName}`
) + afterCursor;
setInputText(newText);
setShowTagSuggestions(false);
setFilteredTags([]);
setSelectedSuggestionIndex(-1);
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 projectStart = beforeCursor.lastIndexOf('+');
const textBeforeProject = inputText
.substring(0, projectStart)
.trim();
const textAfterCursor = afterCursor.trim();
let allowReplacement = false;
if (textAfterCursor === '' || textBeforeProject === '') {
allowReplacement = true;
} else {
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 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;