tududi/frontend/components/Tasks.tsx
2025-12-28 18:40:58 +02:00

1041 lines
52 KiB
TypeScript

import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useSidebar } from '../contexts/SidebarContext';
import TaskList from './Task/TaskList';
import GroupedTaskList from './Task/GroupedTaskList';
import NewTask from './Task/NewTask';
import { Task } from '../entities/Task';
import { getTitleAndIcon } from './Task/getTitleAndIcon';
import { getDescription } from './Task/getDescription';
import { createTask, GroupedTasks } from '../utils/tasksService';
import { useStore } from '../store/useStore';
import { useToast } from './Shared/ToastContext';
import { SortOption } from './Shared/SortFilterButton';
import IconSortDropdown from './Shared/IconSortDropdown';
import { TagIcon, XMarkIcon } from '@heroicons/react/24/solid';
import {
QueueListIcon,
InformationCircleIcon,
MagnifyingGlassIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import { getApiPath } from '../config/paths';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const getSearchPlaceholder = (language: string): string => {
const placeholders: Record<string, string> = {
en: 'Search tasks...',
el: 'Αναζήτηση εργασιών...',
es: 'Buscar tareas...',
de: 'Aufgaben suchen...',
jp: 'タスクを検索...',
ua: 'Пошук завдань...',
};
return placeholders[language] || 'Search tasks...';
};
const Tasks: React.FC = () => {
const { t, i18n } = useTranslation();
const { showSuccessToast } = useToast();
const { isSidebarOpen } = useSidebar();
const [tasks, setTasks] = useState<Task[]>([]);
const projects = useStore((state: any) => state.projectsStore.projects);
const [groupedTasks, setGroupedTasks] = useState<GroupedTasks | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const DEFAULT_LIMIT = 20;
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const isUpcomingView =
query.get('type') === 'upcoming' || location.pathname === '/upcoming';
const status = query.get('status');
const tag = query.get('tag');
useEffect(() => {
if (status === 'completed') {
setShowCompleted(true);
} else if (status === 'active') {
setShowCompleted(false);
} else if (status === null) {
setShowCompleted(true);
}
}, [status, isUpcomingView]);
const displayTasks = useMemo(() => {
let filteredTasks: Task[] = tasks;
if (status === 'completed') {
filteredTasks = filteredTasks.filter((task: Task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return isCompleted;
});
} else if (status === 'active') {
filteredTasks = filteredTasks.filter((task: Task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return !isCompleted;
});
}
if (taskSearchQuery.trim() && !isUpcomingView) {
const queryLower = taskSearchQuery.toLowerCase();
filteredTasks = filteredTasks.filter(
(task: Task) =>
task.name.toLowerCase().includes(queryLower) ||
task.original_name?.toLowerCase().includes(queryLower) ||
task.note?.toLowerCase().includes(queryLower)
);
}
return filteredTasks;
}, [tasks, showCompleted, status, taskSearchQuery, isUpcomingView]);
if (location.pathname === '/upcoming' && !query.get('type')) {
query.set('type', 'upcoming');
}
const { title: stateTitle } = location.state || {};
const title =
stateTitle ||
getTitleAndIcon(query, projects, t, location.pathname).title;
useEffect(() => {
const savedOrderBy =
localStorage.getItem('order_by') || 'created_at:desc';
setOrderBy(savedOrderBy);
const savedGroupBy =
(localStorage.getItem('tasks_group_by') as 'none' | 'project') ||
'none';
setGroupBy(savedGroupBy);
const params = new URLSearchParams(location.search);
if (!params.get('order_by')) {
params.set('order_by', savedOrderBy);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
},
{ replace: true }
);
}
}, [location.pathname]);
useEffect(() => {
if (isUpcomingView) {
setTaskSearchQuery('');
setIsSearchExpanded(false);
}
}, [isUpcomingView]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
const fetchData = async (
resetPagination = true,
options?: {
limitOverride?: number;
forceOffset?: number;
disableHasMore?: boolean;
disablePagination?: boolean;
}
) => {
setLoading(resetPagination);
setError(null);
try {
const tagId = query.get('tag');
const type = query.get('type');
const allTasksUrl = new URLSearchParams(query.toString());
allTasksUrl.set('client_side_filtering', 'true');
if (type === 'upcoming') {
allTasksUrl.set('type', 'upcoming');
allTasksUrl.set('groupBy', 'day');
allTasksUrl.set('maxDays', '7');
allTasksUrl.set('sidebarOpen', isSidebarOpen.toString());
allTasksUrl.set('isMobile', isMobile.toString());
}
if (!options?.disablePagination && type !== 'upcoming') {
const currentOffset =
options?.forceOffset !== undefined
? options.forceOffset
: resetPagination
? 0
: offset;
const limitToUse = options?.limitOverride ?? limit;
allTasksUrl.set('limit', limitToUse.toString());
allTasksUrl.set('offset', currentOffset.toString());
}
const searchParams = allTasksUrl.toString();
const tasksResponse = await fetch(
getApiPath(
`tasks?${searchParams}${tagId ? `&tag=${tagId}` : ''}`
)
);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
if (resetPagination) {
setTasks(tasksData.tasks || []);
setGroupedTasks(tasksData.groupedTasks || null);
if (!options?.disablePagination) {
const limitToUse = options?.limitOverride ?? limit;
setOffset(limitToUse);
}
} else {
setTasks((prev) => [...prev, ...(tasksData.tasks || [])]);
if (tasksData.groupedTasks) {
setGroupedTasks((prev) => {
if (!prev) return tasksData.groupedTasks;
return {
...prev,
...tasksData.groupedTasks,
};
});
}
if (!options?.disablePagination) {
const limitToUse = options?.limitOverride ?? limit;
setOffset((prev) => prev + limitToUse);
}
}
setHasMore(
options?.disableHasMore ||
options?.disablePagination ||
type === 'upcoming'
? false
: tasksData.pagination?.hasMore || false
);
if (tasksData.pagination) {
setTotalCount(tasksData.pagination.total);
} else if (options?.disablePagination || type === 'upcoming') {
setTotalCount(tasksData.tasks?.length || 0);
}
} else {
throw new Error('Failed to fetch tasks.');
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
setIsLoadingMore(false);
}
};
const loadMore = async (all: boolean) => {
if (isLoadingMore) return;
if (!hasMore && !all) return;
setIsLoadingMore(true);
const shouldDisablePagination =
!isUpcomingView && groupBy === 'project';
if (all || shouldDisablePagination) {
const newLimit = totalCount > 0 ? totalCount : 10000;
await fetchData(true, {
limitOverride: newLimit,
forceOffset: 0,
disableHasMore: true,
disablePagination: true,
});
setLimit(DEFAULT_LIMIT);
setHasMore(false);
} else {
await fetchData(false);
}
if (all) {
setHasMore(false);
}
};
useEffect(() => {
const shouldDisablePagination = isUpcomingView || groupBy === 'project';
fetchData(
true,
shouldDisablePagination
? {
disablePagination: true,
disableHasMore: true,
limitOverride: 10000,
forceOffset: 0,
}
: undefined
);
}, [location, isSidebarOpen, isMobile, groupBy, isUpcomingView]);
useEffect(() => {
const handleResize = () => {
const newIsMobile = window.innerWidth < 768;
if (newIsMobile !== isMobile) {
setIsMobile(newIsMobile);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isMobile]);
useEffect(() => {
const handleTaskCreated = (event: CustomEvent) => {
const newTask = event.detail;
if (newTask) {
setTasks((prevTasks) => [newTask, ...prevTasks]);
}
};
window.addEventListener(
'taskCreated',
handleTaskCreated as EventListener
);
return () => {
window.removeEventListener(
'taskCreated',
handleTaskCreated as EventListener
);
};
}, []);
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete('tag');
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
};
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const newTask = await createTask(taskData as Task);
setTasks((prevTasks) => [newTask, ...prevTasks]);
const taskLink = (
<span>
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error('Error creating task:', error);
setError('Error creating task.');
throw error;
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(
getApiPath(`task/${updatedTask.uid}`),
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
}
);
if (response.ok) {
const updatedTaskFromServer = await response.json();
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id
? {
...task,
...updatedTaskFromServer,
subtasks:
updatedTaskFromServer.subtasks ||
task.subtasks ||
[],
}
: task
)
);
} else {
const errorData = await response.json();
console.error('Failed to update task:', errorData.error);
setError('Failed to update task.');
}
} catch (error) {
console.error('Error updating task:', error);
setError('Error updating task.');
}
};
const handleTaskCompletionToggle = (updatedTask: Task) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
if (groupedTasks) {
setGroupedTasks((prevGroupedTasks) => {
if (!prevGroupedTasks) return null;
const newGroupedTasks: GroupedTasks = {};
Object.entries(prevGroupedTasks).forEach(
([groupName, tasks]) => {
newGroupedTasks[groupName] = tasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
);
}
);
return newGroupedTasks;
});
}
};
const handleTaskDelete = async (taskUid: string) => {
try {
const response = await fetch(
getApiPath(`task/${encodeURIComponent(taskUid)}`),
{
method: 'DELETE',
}
);
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.filter((task) => task.uid !== taskUid)
);
} else {
const errorData = await response.json();
console.error('Failed to delete task:', errorData.error);
setError('Failed to delete task.');
}
} catch (error) {
console.error('Error deleting task:', error);
setError('Error deleting task.');
}
};
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem('order_by', order);
const params = new URLSearchParams(location.search);
params.set('order_by', order);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
},
{ replace: true }
);
setDropdownOpen(false);
};
const sortOptions: SortOption[] = [
{ value: 'due_date:asc', label: t('sort.due_date', 'Due Date') },
{ value: 'name:asc', label: t('sort.name', 'Name') },
{ value: 'priority:desc', label: t('sort.priority', 'Priority') },
{ value: 'status:desc', label: t('sort.status', 'Status') },
{ value: 'created_at:desc', label: t('sort.created_at', 'Created At') },
...(status === 'done'
? [
{
value: 'completed_at:desc',
label: t('sort.completed_at', 'Completed At'),
},
]
: []),
];
const description = getDescription(query, projects, t, location.pathname);
const isNewTaskAllowed = () => {
const type = query.get('type');
return status !== 'done' && type !== 'upcoming';
};
return (
<div
className={`w-full pt-4 pb-8 ${isUpcomingView ? 'pl-4 sm:pl-6 md:pl-8' : 'px-2 sm:px-4 lg:px-6'}`}
>
<div
className={`w-full ${isUpcomingView ? '' : 'max-w-5xl mx-auto'}`}
>
{/* Title row with info button and filters dropdown on the right */}
<div
className={`flex items-center justify-between gap-2 min-w-0 ${
isUpcomingView ? 'mb-4 sm:mb-6' : 'mb-8'
}`}
>
<div className="flex items-center flex-1 min-w-0 gap-2">
<h2
className={`${isUpcomingView ? 'text-lg sm:text-xl' : 'text-2xl'} font-light truncate`}
>
{title}
</h2>
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">
{capitalize(tag)}
</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
{/* Info expand/collapse button, search button, show completed toggle, and sort dropdown */}
<div
className={`flex items-center gap-2 flex-shrink-0 ${
isUpcomingView
? 'md:fixed md:right-4 md:top-20 md:px-3 md:py-2 md:z-20'
: ''
}`}
>
<button
onClick={() => setIsInfoExpanded((v) => !v)}
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 tasks information'
}
title={isInfoExpanded ? 'Hide info' : 'About Tasks'}
>
<InformationCircleIcon className="h-5 w-5 text-blue-500" />
<span className="sr-only">
{isInfoExpanded ? 'Hide info' : 'About Tasks'}
</span>
</button>
{!isUpcomingView && (
<button
onClick={() => setIsSearchExpanded((v) => !v)}
className={`flex items-center transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset rounded-lg p-2 ${
isSearchExpanded
? 'bg-blue-50/70 dark:bg-blue-900/20'
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
aria-expanded={isSearchExpanded}
aria-label={
isSearchExpanded
? 'Collapse search panel'
: 'Show search input'
}
title={
isSearchExpanded
? 'Hide search'
: 'Search Tasks'
}
>
<MagnifyingGlassIcon className="h-5 w-5 text-gray-600 dark:text-gray-200" />
<span className="sr-only">
{isSearchExpanded
? 'Hide search'
: 'Search Tasks'}
</span>
</button>
)}
<IconSortDropdown
options={sortOptions}
value={orderBy}
onChange={handleSortChange}
ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
title={t('tasks.sortTasks', 'Sort tasks')}
dropdownLabel={t('tasks.sortBy', 'Sort by')}
align="right"
footerContent={
<div className="space-y-3">
{!isUpcomingView && (
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
{t('tasks.groupBy', 'Group by')}
</div>
<div className="py-1">
{['none', 'project'].map(
(val) => (
<button
key={val}
onClick={() => {
setGroupBy(
val as
| 'none'
| 'project'
);
localStorage.setItem(
'tasks_group_by',
val
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
groupBy === val
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{val ===
'project'
? t(
'tasks.groupByProject',
'Project'
)
: t(
'tasks.grouping.none',
'None'
)}
</span>
{groupBy ===
val && (
<CheckIcon className="h-4 w-4" />
)}
</button>
)
)}
</div>
</div>
)}
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div className="py-1 space-y-1">
{[
{
key: 'active',
label: t(
'tasks.open',
'Open'
),
},
{
key: 'all',
label: t(
'tasks.all',
'All'
),
},
{
key: 'completed',
label: t(
'tasks.completed',
'Completed'
),
},
].map((opt) => {
const isActive =
(opt.key === 'all' &&
status === null) ||
(opt.key === 'completed' &&
status ===
'completed') ||
(opt.key === 'active' &&
status === 'active');
return (
<button
key={opt.key}
type="button"
onClick={() => {
if (
opt.key ===
'completed'
) {
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'completed'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else if (
opt.key ===
'all'
) {
const params =
new URLSearchParams(
location.search
);
params.delete(
'status'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else {
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'active'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
}
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{opt.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.direction', 'Direction')}
</div>
<div className="py-1">
{[
{
key: 'asc',
label: t(
'tasks.ascending',
'Ascending'
),
},
{
key: 'desc',
label: t(
'tasks.descending',
'Descending'
),
},
].map((dir) => {
const currentDirection =
orderBy.split(':')[1] ||
'asc';
const isActive =
currentDirection ===
dir.key;
return (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
);
const newOrderBy = `${field}:${dir.key}`;
handleSortChange(
newOrderBy
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{dir.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
</div>
}
/>
</div>
</div>
{/* Info/description section with large info icon, collapsible */}
<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-400 leading-relaxed">
{description}
</p>
</div>
</div>
</div>
{/* Search input section, collapsible - hidden in upcoming view */}
{!isUpcomingView && (
<div
className={`transition-all duration-300 ease-in-out ${
isSearchExpanded
? 'max-h-24 opacity-100 mb-4'
: 'max-h-0 opacity-0 mb-0'
} overflow-hidden`}
>
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-4 py-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-600 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={getSearchPlaceholder(
i18n.language
)}
value={taskSearchQuery}
onChange={(e) =>
setTaskSearchQuery(e.target.value)
}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
)}
{loading ? (
<p>{t('common.loading', 'Loading...')}</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
{isNewTaskAllowed() && (
<div className="mb-6">
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({
name: taskName,
status: 'not_started',
})
}
/>
</div>
)}
{displayTasks.length > 0 ||
(groupedTasks &&
Object.keys(groupedTasks).length > 0) ? (
<>
{query.get('type') === 'upcoming' ? (
<GroupedTaskList
tasks={displayTasks}
groupedTasks={groupedTasks}
groupBy="none"
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projects}
hideProjectName={false}
onToggleToday={undefined}
showCompletedTasks={showCompleted}
searchQuery={taskSearchQuery}
/>
) : groupBy === 'project' ? (
<GroupedTaskList
tasks={displayTasks}
groupedTasks={null}
groupBy="project"
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projects}
hideProjectName={false}
onToggleToday={undefined}
showCompletedTasks={showCompleted}
searchQuery={taskSearchQuery}
/>
) : (
<TaskList
tasks={displayTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projects}
onToggleToday={undefined}
showCompletedTasks={showCompleted}
/>
)}
{/* Load more button - hide in upcoming view */}
{!isUpcomingView && hasMore && (
<div className="flex justify-center pt-4 gap-3">
<button
onClick={() => loadMore(false)}
disabled={isLoadingMore}
className="inline-flex items-center px-5 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"
>
{isLoadingMore ? (
<>
<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(
'common.loading',
'Loading...'
)}
</>
) : (
<>
<QueueListIcon className="h-4 w-4 mr-2" />
{t(
'common.loadMore',
'Load More'
)}
</>
)}
</button>
<button
onClick={() => loadMore(true)}
disabled={isLoadingMore}
className="inline-flex items-center px-5 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"
>
{t('common.showAll', 'Show All')}
</button>
</div>
)}
{/* Pagination info - hide in upcoming view */}
{!isUpcomingView && tasks.length > 0 && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} tasks',
{
current: tasks.length,
total: totalCount,
}
)}
</div>
)}
</>
) : (
<div className="flex justify-center items-center mt-4">
<div className="w-full max-w bg-black/2 dark:bg-gray-900/25 rounded-l px-10 py-24 flex flex-col items-center opacity-95">
<InformationCircleIcon className="h-20 w-20 text-gray-400 opacity-30 mb-6" />
<p className="text-2xl font-light text-center text-gray-600 dark:text-gray-300 mb-2">
{t(
'tasks.noTasksAvailable',
'No tasks available.'
)}
</p>
<p className="text-base text-center text-gray-400 dark:text-gray-400">
{t(
'tasks.blankSlateHint',
'Start by creating a new task or changing your filters.'
)}
</p>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default Tasks;