Fix project loading, task detail panel, collapsible sidebar, due dates

This commit is contained in:
2026-01-28 18:32:37 +00:00
parent 094e29d838
commit b87cb69ae0
6 changed files with 527 additions and 15 deletions

4
dist/index.html vendored
View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title>
<script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
<script type="module" crossorigin src="/assets/index-vdiwxbr4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cuyvk5mt.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Plus, Calendar, Flag, Tag, X, UserCircle } from 'lucide-react';
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
import type { Priority } from '@/types';
import { cn, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
@@ -117,25 +117,28 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
{/* Options row */}
<div className="flex items-center gap-2 mt-3 flex-wrap">
{/* Due date */}
<div className="relative">
<div className="inline-flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-gray-400" />
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded border transition-colors',
'text-xs rounded border px-2 py-1 outline-none focus:border-blue-400 cursor-pointer bg-white',
dueDate
? 'border-blue-200 bg-blue-50 text-blue-600'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
)}
/>
{dueDate && (
<button
type="button"
onClick={() => setDueDate('')}
className="p-0.5 text-gray-400 hover:text-gray-600"
>
<Calendar className="w-3.5 h-3.5" />
{dueDate ? new Date(dueDate).toLocaleDateString() : 'Due date'}
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Priority selector */}

View File

@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { TaskDetail } from './TaskDetail';
import { useAuthStore } from '@/stores/auth';
import { useTasksStore } from '@/stores/tasks';
export function Layout() {
const { isAuthenticated, isLoading } = useAuthStore();
const { fetchProjects, fetchLabels } = useTasksStore();
const { fetchProjects, fetchLabels, selectedTask, setSelectedTask } = useTasksStore();
useEffect(() => {
if (isAuthenticated) {
@@ -36,6 +37,12 @@ export function Layout() {
<main className="flex-1 overflow-y-auto p-8">
<Outlet />
</main>
{selectedTask && (
<TaskDetail
task={selectedTask}
onClose={() => setSelectedTask(null)}
/>
)}
</div>
);
}

View File

@@ -2,7 +2,8 @@ import { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check,
PanelLeftClose, PanelLeftOpen
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
@@ -13,6 +14,8 @@ const PROJECT_COLORS = [
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
export function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
@@ -24,6 +27,13 @@ export function Sidebar() {
const [newProjectName, setNewProjectName] = useState('');
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true';
} catch {
return false;
}
});
const newProjectInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -32,6 +42,14 @@ export function Sidebar() {
}
}, [showNewProject]);
const toggleCollapsed = () => {
const next = !isCollapsed;
setIsCollapsed(next);
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
} catch {}
};
const handleCreateProject = async () => {
if (!newProjectName.trim() || isCreatingProject) return;
setIsCreatingProject(true);
@@ -68,6 +86,92 @@ export function Sidebar() {
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
];
// Collapsed sidebar
if (isCollapsed) {
return (
<aside className="w-14 h-screen bg-gray-50 border-r border-gray-200 flex flex-col items-center">
{/* Expand button */}
<div className="p-2 pt-3">
<button
onClick={toggleCollapsed}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Expand sidebar"
>
<PanelLeftOpen className="w-4 h-4" />
</button>
</div>
{/* User avatar */}
<div className="p-2">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium" title={user?.name}>
{user?.name?.charAt(0).toUpperCase()}
</div>
</div>
{/* Nav icons */}
<nav className="flex-1 py-2 space-y-1">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-lg transition-colors',
location.pathname === item.path
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
)}
title={item.label}
>
<item.icon className="w-4 h-4" style={{ color: item.color }} />
</Link>
))}
{/* Project icons */}
<div className="pt-3 border-t border-gray-200 mt-3 space-y-1">
{regularProjects.slice(0, 5).map((project) => (
<Link
key={project.id}
to={`/project/${project.id}`}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-lg transition-colors',
location.pathname.startsWith(`/project/${project.id}`)
? 'bg-blue-50'
: 'hover:bg-gray-100'
)}
title={project.name}
>
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: project.color }}
/>
</Link>
))}
</div>
</nav>
{/* Bottom icons */}
<div className="p-2 border-t border-gray-200 space-y-1">
{user?.role === 'admin' && (
<Link
to="/admin"
className="flex items-center justify-center w-10 h-10 rounded-lg text-gray-700 hover:bg-gray-100"
title="Admin"
>
<Settings className="w-4 h-4" />
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center justify-center w-10 h-10 rounded-lg text-gray-700 hover:bg-gray-100"
title="Sign out"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</aside>
);
}
return (
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
{/* User section */}
@@ -80,6 +184,13 @@ export function Sidebar() {
<p className="text-sm font-medium text-gray-900 truncate">{user?.name}</p>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div>
<button
onClick={toggleCollapsed}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Collapse sidebar"
>
<PanelLeftClose className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -0,0 +1,377 @@
import { useState, useEffect, useRef } from 'react';
import {
X, Check, Trash2, Calendar, Flag, User, FolderOpen,
Tag, MessageSquare, ChevronRight, AlertCircle
} from 'lucide-react';
import type { Task, Priority, Comment } from '@/types';
import { cn, formatDate, getPriorityColor, getPriorityLabel } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
import { api } from '@/lib/api';
interface TaskDetailProps {
task: Task;
onClose: () => void;
}
export function TaskDetail({ task, onClose }: TaskDetailProps) {
const { updateTask, deleteTask, toggleComplete, projects, users, fetchUsers } = useTasksStore();
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description || '');
const [dueDate, setDueDate] = useState(task.dueDate || '');
const [priority, setPriority] = useState<Priority>(task.priority);
const [assigneeId, setAssigneeId] = useState(task.assigneeId || '');
const [comments, setComments] = useState<Comment[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setTitle(task.title);
setDescription(task.description || '');
setDueDate(task.dueDate || '');
setPriority(task.priority);
setAssigneeId(task.assigneeId || '');
}, [task]);
useEffect(() => {
if (users.length === 0) {
fetchUsers();
}
}, []);
useEffect(() => {
api.getComments(task.id).then(setComments).catch(() => setComments([]));
}, [task.id]);
// Save on blur for title/description
const handleTitleBlur = () => {
if (title.trim() && title !== task.title) {
updateTask(task.id, { title: title.trim() });
}
};
const handleDescriptionBlur = () => {
if (description !== (task.description || '')) {
updateTask(task.id, { description: description.trim() || undefined });
}
};
const handleDueDateChange = (value: string) => {
setDueDate(value);
updateTask(task.id, { dueDate: value || undefined });
};
const handlePriorityChange = (value: Priority) => {
setPriority(value);
updateTask(task.id, { priority: value });
};
const handleAssigneeChange = (value: string) => {
setAssigneeId(value);
updateTask(task.id, { assigneeId: value || undefined });
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await deleteTask(task.id);
onClose();
} catch (error) {
console.error('Failed to delete task:', error);
setIsDeleting(false);
}
};
const handleComplete = async () => {
await toggleComplete(task.id);
};
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const taskProject = projects.find(p => p.id === task.projectId);
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/20 z-40"
onClick={onClose}
/>
{/* Panel */}
<div
ref={panelRef}
className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-xl z-50 flex flex-col animate-slide-in"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<div className="flex items-center gap-2">
<button
onClick={handleComplete}
className={cn(
'flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors',
task.isCompleted
? 'bg-gray-400 border-gray-400'
: 'border-gray-300 hover:border-gray-400'
)}
style={{
borderColor: !task.isCompleted ? getPriorityColor(task.priority) : undefined
}}
>
{task.isCompleted && <Check className="w-4 h-4 text-white" />}
</button>
<span className={cn(
'text-sm font-medium',
task.isCompleted ? 'text-gray-500' : 'text-gray-900'
)}>
{task.isCompleted ? 'Completed' : 'Mark complete'}
</span>
</div>
<button
onClick={onClose}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
{/* Title */}
<input
ref={titleRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleTitleBlur}
onKeyDown={(e) => e.key === 'Enter' && titleRef.current?.blur()}
className="w-full text-lg font-semibold text-gray-900 border-none outline-none placeholder-gray-400 bg-transparent"
placeholder="Task name"
/>
{/* Description */}
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={handleDescriptionBlur}
placeholder="Add a description..."
rows={3}
className="w-full text-sm text-gray-600 border border-gray-200 rounded-lg p-2 outline-none focus:border-blue-400 resize-none placeholder-gray-400"
/>
{/* Properties */}
<div className="space-y-3 pt-2">
{/* Project */}
<div className="flex items-center gap-3">
<FolderOpen className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-500 w-20">Project</span>
<div className="flex items-center gap-2">
{taskProject && (
<>
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: taskProject.color }}
/>
<span className="text-sm text-gray-900">{taskProject.name}</span>
</>
)}
</div>
</div>
{/* Due Date */}
<div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-500 w-20">Due date</span>
<input
type="date"
value={dueDate}
onChange={(e) => handleDueDateChange(e.target.value)}
className={cn(
'text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400',
dueDate ? 'text-gray-900' : 'text-gray-400'
)}
/>
{dueDate && (
<button
onClick={() => handleDueDateChange('')}
className="p-0.5 text-gray-400 hover:text-gray-600"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Priority */}
<div className="flex items-center gap-3">
<Flag className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-500 w-20">Priority</span>
<select
value={priority}
onChange={(e) => handlePriorityChange(e.target.value as Priority)}
className="text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400 bg-white cursor-pointer"
style={{ color: getPriorityColor(priority) }}
>
<option value="p1">Priority 1</option>
<option value="p2">Priority 2</option>
<option value="p3">Priority 3</option>
<option value="p4">Priority 4</option>
</select>
</div>
{/* Assignee */}
<div className="flex items-center gap-3">
<User className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-500 w-20">Assignee</span>
<select
value={assigneeId}
onChange={(e) => handleAssigneeChange(e.target.value)}
className={cn(
'text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400 bg-white cursor-pointer',
assigneeId ? 'text-gray-900' : 'text-gray-400'
)}
>
<option value="">Unassigned</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
{/* Labels */}
{task.taskLabels && task.taskLabels.length > 0 && (
<div className="flex items-center gap-3">
<Tag className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-500 w-20">Labels</span>
<div className="flex gap-1 flex-wrap">
{task.taskLabels.map(({ label }) => (
<span
key={label.id}
className="px-2 py-0.5 text-xs rounded-full"
style={{
backgroundColor: `${label.color}20`,
color: label.color,
}}
>
{label.name}
</span>
))}
</div>
</div>
)}
</div>
{/* Subtasks */}
{task.subtasks && task.subtasks.length > 0 && (
<div className="pt-4 border-t border-gray-100">
<h3 className="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<ChevronRight className="w-4 h-4" />
Subtasks ({task.subtasks.filter(s => s.isCompleted).length}/{task.subtasks.length})
</h3>
<div className="space-y-1 ml-6">
{task.subtasks.map((subtask) => (
<div key={subtask.id} className="flex items-center gap-2 py-1">
<div
className={cn(
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
subtask.isCompleted
? 'bg-gray-400 border-gray-400'
: 'border-gray-300'
)}
>
{subtask.isCompleted && <Check className="w-2.5 h-2.5 text-white" />}
</div>
<span className={cn(
'text-sm',
subtask.isCompleted ? 'text-gray-500 line-through' : 'text-gray-900'
)}>
{subtask.title}
</span>
</div>
))}
</div>
</div>
)}
{/* Comments */}
<div className="pt-4 border-t border-gray-100">
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Comments ({comments.length})
</h3>
{comments.length === 0 ? (
<p className="text-sm text-gray-400 ml-6">No comments yet</p>
) : (
<div className="space-y-3 ml-6">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium">
{comment.user?.name?.charAt(0).toUpperCase() || '?'}
</span>
<span className="text-xs font-medium text-gray-700">
{comment.user?.name || 'Unknown'}
</span>
<span className="text-xs text-gray-400">
{new Date(comment.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-sm text-gray-600">{comment.content}</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 px-4 py-3 flex items-center justify-between">
<span className="text-xs text-gray-400">
Created {new Date(task.createdAt).toLocaleDateString()}
</span>
<div className="flex items-center gap-2">
{showDeleteConfirm ? (
<>
<span className="text-xs text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Delete this task?
</span>
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-2 py-1 text-xs text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -116,3 +116,17 @@ body {
.priority-dot-2 { background: var(--priority-2); }
.priority-dot-3 { background: var(--priority-3); }
.priority-dot-4 { background: var(--priority-4); }
/* Slide-in animation for task detail panel */
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in {
animation: slide-in-right 0.2s ease-out;
}