596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Task } from '../../entities/Task';
|
|
import { Project } from '../../entities/Project';
|
|
import { Note } from '../../entities/Note';
|
|
import {
|
|
loadInboxItemsToStore,
|
|
loadMoreInboxItemsToStore,
|
|
processInboxItemWithStore,
|
|
deleteInboxItemWithStore,
|
|
updateInboxItemWithStore,
|
|
} from '../../utils/inboxService';
|
|
import InboxItemDetail from './InboxItemDetail';
|
|
import { useToast } from '../Shared/ToastContext';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { InboxIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
|
import LoadingScreen from '../Shared/LoadingScreen';
|
|
import ProjectModal from '../Project/ProjectModal';
|
|
import NoteModal from '../Note/NoteModal';
|
|
import QuickCaptureInput from './QuickCaptureInput';
|
|
import { createTask } from '../../utils/tasksService';
|
|
import { createProject } from '../../utils/projectsService';
|
|
import { createNote } from '../../utils/notesService';
|
|
import { isUrl } from '../../utils/urlService';
|
|
import { fetchAreas } from '../../utils/areasService';
|
|
import { fetchProjects } from '../../utils/projectsService';
|
|
import { useStore } from '../../store/useStore';
|
|
|
|
const InboxItems: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { showSuccessToast, showErrorToast } = useToast();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const [hasInitialized, setHasInitialized] = useState(false);
|
|
|
|
const { inboxItems, isLoading, pagination } = useStore(
|
|
(state) => state.inboxStore
|
|
);
|
|
const {
|
|
areas,
|
|
setAreas,
|
|
setError: setAreasError,
|
|
} = useStore((state) => state.areasStore);
|
|
const {
|
|
loadTags,
|
|
hasLoaded: tagsHasLoaded,
|
|
isLoading: tagsLoading,
|
|
} = useStore((state) => state.tagsStore);
|
|
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
|
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
|
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
|
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
|
|
|
|
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
|
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
|
|
|
const [currentConversionItemUid, setCurrentConversionItemUid] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
const urlPageSize = searchParams.get('loaded');
|
|
const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20;
|
|
|
|
loadInboxItemsToStore(true, currentLoadedCount);
|
|
setHasInitialized(true);
|
|
|
|
const loadInitialProjects = async () => {
|
|
try {
|
|
const projectData = await fetchProjects();
|
|
setProjects(Array.isArray(projectData) ? projectData : []);
|
|
} catch (error) {
|
|
console.error('Failed to load initial projects:', error);
|
|
setProjects([]);
|
|
}
|
|
};
|
|
loadInitialProjects();
|
|
|
|
const loadInitialAreas = async () => {
|
|
try {
|
|
const areasData = await fetchAreas();
|
|
setAreas(areasData);
|
|
} catch (error) {
|
|
console.error('Failed to load initial areas:', error);
|
|
setAreasError(true);
|
|
}
|
|
};
|
|
loadInitialAreas();
|
|
|
|
const loadInitialTags = async () => {
|
|
if (!tagsHasLoaded && !tagsLoading) {
|
|
try {
|
|
await loadTags();
|
|
} catch (error) {
|
|
console.error('Failed to load initial tags:', error);
|
|
}
|
|
}
|
|
};
|
|
loadInitialTags();
|
|
const handleForceReload = () => {
|
|
setTimeout(() => {
|
|
const currentInboxStore = useStore.getState().inboxStore;
|
|
const currentCount = currentInboxStore.inboxItems.length;
|
|
loadInboxItemsToStore(false, currentCount);
|
|
}, 500);
|
|
};
|
|
|
|
const handleInboxItemsUpdated = (
|
|
event: CustomEvent<{ count: number; firstItemContent: string }>
|
|
) => {
|
|
if (event.detail.count > 0) {
|
|
showSuccessToast(
|
|
t(
|
|
'inbox.newTelegramItem',
|
|
'New item from Telegram: {{content}}',
|
|
{
|
|
content: event.detail.firstItemContent,
|
|
}
|
|
)
|
|
);
|
|
|
|
if (event.detail.count > 1) {
|
|
showSuccessToast(
|
|
t(
|
|
'inbox.multipleNewItems',
|
|
'{{count}} more new items added',
|
|
{
|
|
count: event.detail.count - 1,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pollInterval = setInterval(() => {
|
|
const currentInboxStore = useStore.getState().inboxStore;
|
|
const currentCount = currentInboxStore.inboxItems.length;
|
|
loadInboxItemsToStore(false, currentCount);
|
|
}, 15000);
|
|
|
|
window.addEventListener('forceInboxReload', handleForceReload);
|
|
window.addEventListener(
|
|
'inboxItemsUpdated',
|
|
handleInboxItemsUpdated as EventListener
|
|
);
|
|
|
|
return () => {
|
|
clearInterval(pollInterval);
|
|
window.removeEventListener('forceInboxReload', handleForceReload);
|
|
window.removeEventListener(
|
|
'inboxItemsUpdated',
|
|
handleInboxItemsUpdated as EventListener
|
|
);
|
|
};
|
|
}, [t, showSuccessToast]);
|
|
|
|
useEffect(() => {
|
|
if (!hasInitialized) return;
|
|
|
|
const urlPageSize = searchParams.get('loaded');
|
|
const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0;
|
|
|
|
if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) {
|
|
setSearchParams(
|
|
{ loaded: inboxItems.length.toString() },
|
|
{ replace: true }
|
|
);
|
|
} else if (inboxItems.length <= 20 && urlLoadedCount > 0) {
|
|
setSearchParams({}, { replace: true });
|
|
}
|
|
}, [inboxItems.length, hasInitialized]);
|
|
|
|
const handleProcessItem = async (
|
|
uid: string,
|
|
showToast: boolean = true
|
|
) => {
|
|
try {
|
|
await processInboxItemWithStore(uid);
|
|
if (showToast) {
|
|
showSuccessToast(t('inbox.itemProcessed'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to process inbox item:', error);
|
|
showErrorToast(t('inbox.processError'));
|
|
}
|
|
};
|
|
|
|
const handleUpdateItem = async (
|
|
uid: string,
|
|
newContent: string
|
|
): Promise<void> => {
|
|
try {
|
|
await updateInboxItemWithStore(uid, newContent);
|
|
showSuccessToast(t('inbox.itemUpdated'));
|
|
} catch (error) {
|
|
console.error('Failed to update inbox item:', error);
|
|
showErrorToast(t('inbox.updateError'));
|
|
}
|
|
};
|
|
|
|
const handleDeleteItem = async (uid: string) => {
|
|
try {
|
|
await deleteInboxItemWithStore(uid);
|
|
showSuccessToast(t('inbox.itemDeleted'));
|
|
} catch (error) {
|
|
console.error('Failed to delete inbox item:', error);
|
|
showErrorToast(t('inbox.deleteError'));
|
|
}
|
|
};
|
|
|
|
const createTaskAndHandleConversion = async (
|
|
taskData: Task,
|
|
options: { inboxItemUid?: string; navigateAfterCreate?: boolean } = {}
|
|
) => {
|
|
try {
|
|
const createdTask = await createTask(taskData);
|
|
const taskLink = (
|
|
<span>
|
|
{t('task.created', 'Task')}{' '}
|
|
<a
|
|
href={`/task/${createdTask.uid}`}
|
|
className="text-green-200 underline hover:text-green-100"
|
|
>
|
|
{createdTask.name}
|
|
</a>{' '}
|
|
{t('task.createdSuccessfully', 'created successfully!')}
|
|
</span>
|
|
);
|
|
showSuccessToast(taskLink);
|
|
|
|
const inboxUid =
|
|
options.inboxItemUid ?? currentConversionItemUid ?? undefined;
|
|
|
|
if (inboxUid) {
|
|
await handleProcessItem(inboxUid, false);
|
|
if (!options.inboxItemUid) {
|
|
setCurrentConversionItemUid(null);
|
|
}
|
|
}
|
|
|
|
if (options.navigateAfterCreate && createdTask.uid) {
|
|
navigate(`/task/${createdTask.uid}`, { state: { from: location.pathname + location.search } });
|
|
}
|
|
|
|
return createdTask;
|
|
} catch (error) {
|
|
console.error('Failed to create task:', error);
|
|
showErrorToast(t('task.createError'));
|
|
throw error;
|
|
} finally {
|
|
if (options.inboxItemUid) {
|
|
setCurrentConversionItemUid(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleOpenTaskModal = async (task: Task, inboxItemUid?: string) => {
|
|
if (inboxItemUid) {
|
|
setCurrentConversionItemUid(inboxItemUid);
|
|
}
|
|
|
|
try {
|
|
await createTaskAndHandleConversion(task, {
|
|
inboxItemUid,
|
|
navigateAfterCreate: true,
|
|
});
|
|
} catch {
|
|
// Errors are already reported via toast notifications
|
|
}
|
|
};
|
|
|
|
const handleOpenProjectModal = async (
|
|
project: Project | null,
|
|
inboxItemUid?: string
|
|
) => {
|
|
try {
|
|
try {
|
|
const areasData = await fetchAreas();
|
|
setAreas(areasData);
|
|
} catch (error) {
|
|
console.error('Failed to load areas:', error);
|
|
showErrorToast(t('area.loadError', 'Failed to load areas'));
|
|
setAreas([]);
|
|
}
|
|
|
|
setProjectToEdit(project);
|
|
|
|
if (inboxItemUid) {
|
|
setCurrentConversionItemUid(inboxItemUid);
|
|
}
|
|
|
|
setIsProjectModalOpen(true);
|
|
} catch (error) {
|
|
console.error('Failed to open project modal:', error);
|
|
}
|
|
};
|
|
|
|
const handleOpenNoteModal = async (
|
|
note: Note | null,
|
|
inboxItemUid?: string
|
|
) => {
|
|
if (note && note.content && isUrl(note.content.trim())) {
|
|
if (!note.tags) {
|
|
note.tags = [{ name: 'bookmark' }];
|
|
} else if (!note.tags.some((tag) => tag.name === 'bookmark')) {
|
|
note.tags.push({ name: 'bookmark' });
|
|
}
|
|
}
|
|
|
|
setNoteToEdit(note);
|
|
|
|
if (inboxItemUid) {
|
|
setCurrentConversionItemUid(inboxItemUid);
|
|
}
|
|
|
|
setIsNoteModalOpen(true);
|
|
};
|
|
|
|
const handleSaveTask = async (task: Task) => {
|
|
await createTaskAndHandleConversion(task);
|
|
};
|
|
|
|
const handleSaveProject = async (project: Project) => {
|
|
try {
|
|
await createProject(project);
|
|
showSuccessToast(t('project.createSuccess'));
|
|
|
|
if (currentConversionItemUid !== null) {
|
|
await handleProcessItem(currentConversionItemUid, false);
|
|
setCurrentConversionItemUid(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create project:', error);
|
|
showErrorToast(t('project.createError'));
|
|
}
|
|
};
|
|
|
|
const handleSaveNote = async (note: Note) => {
|
|
try {
|
|
const noteContent = note.content || '';
|
|
const isBookmarkContent = isUrl(noteContent.trim());
|
|
|
|
if (!note.tags) {
|
|
note.tags = [];
|
|
}
|
|
|
|
if (
|
|
isBookmarkContent &&
|
|
!note.tags.some((tag) => tag.name === 'bookmark')
|
|
) {
|
|
note.tags = [...note.tags, { name: 'bookmark' }];
|
|
}
|
|
|
|
await createNote(note);
|
|
showSuccessToast(
|
|
t('note.createSuccess', 'Note created successfully')
|
|
);
|
|
|
|
if (currentConversionItemUid !== null) {
|
|
await handleProcessItem(currentConversionItemUid, false);
|
|
setCurrentConversionItemUid(null);
|
|
}
|
|
|
|
setIsNoteModalOpen(false);
|
|
} catch (error) {
|
|
console.error('Failed to create note:', error);
|
|
showErrorToast(t('note.createError', 'Failed to create note'));
|
|
}
|
|
};
|
|
|
|
const handleCreateProject = async (name: string): Promise<Project> => {
|
|
try {
|
|
const project = await createProject({ name, status: 'planned' });
|
|
showSuccessToast(t('project.createSuccess'));
|
|
return project;
|
|
} catch (error) {
|
|
console.error('Failed to create project:', error);
|
|
showErrorToast(t('project.createError'));
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleLoadMore = async () => {
|
|
try {
|
|
await loadMoreInboxItemsToStore();
|
|
} catch (error) {
|
|
console.error('Failed to load more inbox items:', error);
|
|
showErrorToast(
|
|
t('inbox.loadMoreError', 'Failed to load more items')
|
|
);
|
|
}
|
|
};
|
|
|
|
if (isLoading && inboxItems.length === 0) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full px-2 sm:px-4 lg:px-6 pt-4 pb-8">
|
|
<div className="w-full max-w-5xl mx-auto">
|
|
<div className="flex items-center mb-8 justify-between">
|
|
<div className="flex items-center">
|
|
<h1 className="text-2xl font-light">
|
|
{t('inbox.title')}
|
|
</h1>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsInfoExpanded(!isInfoExpanded)}
|
|
className={`flex items-center hover:bg-blue-100/50 dark:hover:bg-blue-800/20 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset rounded-lg${isInfoExpanded ? ' bg-blue-50/70 dark:bg-blue-900/20' : ''} p-2`}
|
|
aria-expanded={isInfoExpanded}
|
|
aria-label={
|
|
isInfoExpanded
|
|
? 'Collapse info panel'
|
|
: 'Show inbox information'
|
|
}
|
|
title={isInfoExpanded ? 'Hide info' : 'About Inbox'}
|
|
>
|
|
<InformationCircleIcon className="h-5 w-5 text-blue-500" />
|
|
<span className="sr-only">
|
|
{isInfoExpanded ? 'Hide info' : 'About Inbox'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className={`transition-all duration-300 ease-in-out ${
|
|
isInfoExpanded
|
|
? 'max-h-96 opacity-100 mb-6'
|
|
: 'max-h-0 opacity-0 mb-0'
|
|
} overflow-hidden`}
|
|
>
|
|
<div className="bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 rounded-lg px-6 py-5 flex items-start gap-4">
|
|
<div className="flex-shrink-0">
|
|
<InformationCircleIcon className="h-12 w-12 text-blue-400 opacity-20" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
|
{t(
|
|
'taskViews.inbox',
|
|
"Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don't have a due date will appear here. This is your 'brain dump' area where you can quickly note down tasks and organize them later."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<QuickCaptureInput
|
|
onTaskCreate={handleSaveTask}
|
|
onNoteCreate={handleSaveNote}
|
|
projects={projects}
|
|
autoFocus={true}
|
|
openTaskModal={handleOpenTaskModal}
|
|
openProjectModal={handleOpenProjectModal}
|
|
openNoteModal={handleOpenNoteModal}
|
|
cardClassName="mb-4"
|
|
/>
|
|
|
|
{inboxItems.length > 0 && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
{inboxItems.map((item) => (
|
|
<InboxItemDetail
|
|
key={item.uid || item.id}
|
|
item={item}
|
|
onDelete={handleDeleteItem}
|
|
onUpdate={handleUpdateItem}
|
|
openTaskModal={handleOpenTaskModal}
|
|
openProjectModal={handleOpenProjectModal}
|
|
openNoteModal={handleOpenNoteModal}
|
|
projects={projects}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{pagination.hasMore && (
|
|
<div className="flex justify-center pt-4">
|
|
<button
|
|
onClick={handleLoadMore}
|
|
disabled={isLoading}
|
|
className="inline-flex items-center px-6 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<svg
|
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500"
|
|
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('inbox.loading', 'Loading...')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<InboxIcon className="h-4 w-4 mr-2" />
|
|
{t(
|
|
'inbox.loadMore',
|
|
'Load more inbox items'
|
|
)}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{inboxItems.length > 0 && (
|
|
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2">
|
|
{t(
|
|
'inbox.showingItems',
|
|
'Showing {{current}} of {{total}} items',
|
|
{
|
|
current: inboxItems.length,
|
|
total: pagination.total,
|
|
}
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(() => {
|
|
return (
|
|
isProjectModalOpen &&
|
|
(() => {
|
|
try {
|
|
return (
|
|
<ProjectModal
|
|
isOpen={isProjectModalOpen}
|
|
onClose={() => {
|
|
setIsProjectModalOpen(false);
|
|
setProjectToEdit(null);
|
|
}}
|
|
onSave={handleSaveProject}
|
|
project={projectToEdit || undefined}
|
|
areas={
|
|
Array.isArray(areas) ? areas : []
|
|
}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
'ProjectModal rendering error:',
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
})()
|
|
);
|
|
})()}
|
|
|
|
{(() => {
|
|
try {
|
|
return (
|
|
<NoteModal
|
|
isOpen={isNoteModalOpen}
|
|
onClose={() => {
|
|
setIsNoteModalOpen(false);
|
|
setNoteToEdit(null);
|
|
}}
|
|
onSave={handleSaveNote}
|
|
note={noteToEdit}
|
|
projects={
|
|
Array.isArray(projects) ? projects : []
|
|
}
|
|
onCreateProject={handleCreateProject}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.error('NoteModal rendering error:', error);
|
|
return null;
|
|
}
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InboxItems;
|