Initial todo app setup
- Backend: Bun + Elysia + Drizzle ORM + PostgreSQL - Frontend: React + Vite + TailwindCSS + Zustand - Auth: better-auth with invite-only system - Features: Tasks, Projects, Sections, Labels, Comments - Hammer API: Dedicated endpoints for AI assistant integration - Unit tests: 24 passing tests - Docker: Compose file for deployment
This commit is contained in:
182
apps/web/src/components/AddTask.tsx
Normal file
182
apps/web/src/components/AddTask.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useRef, useEffect } from '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';
|
||||
|
||||
interface AddTaskProps {
|
||||
projectId?: string;
|
||||
sectionId?: string;
|
||||
parentId?: string;
|
||||
onClose?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = false }: AddTaskProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(autoFocus);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [priority, setPriority] = useState<Priority>('p4');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { createTask, projects } = useTasksStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createTask({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
projectId,
|
||||
sectionId,
|
||||
parentId,
|
||||
dueDate: dueDate || undefined,
|
||||
priority,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setDueDate('');
|
||||
setPriority('p4');
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
setIsExpanded(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsExpanded(false);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setDueDate('');
|
||||
setPriority('p4');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-500 hover:text-blue-600 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add task</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border border-gray-200 rounded-lg p-3 bg-white shadow-sm">
|
||||
{/* Title input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task name"
|
||||
className="w-full text-sm font-medium text-gray-900 placeholder-gray-400 border-none outline-none"
|
||||
/>
|
||||
|
||||
{/* Description input */}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description"
|
||||
rows={2}
|
||||
className="w-full mt-2 text-sm text-gray-600 placeholder-gray-400 border-none outline-none resize-none"
|
||||
/>
|
||||
|
||||
{/* Options row */}
|
||||
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||
{/* Due date */}
|
||||
<div className="relative">
|
||||
<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',
|
||||
dueDate
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{dueDate ? new Date(dueDate).toLocaleDateString() : 'Due date'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Priority selector */}
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Priority)}
|
||||
className="appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50"
|
||||
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>
|
||||
|
||||
{/* Project selector (if not in a specific project context) */}
|
||||
{!projectId && projects.length > 0 && (
|
||||
<select
|
||||
className="appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50 text-gray-600"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.isInbox ? '📥 Inbox' : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || isSubmitting}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded transition-colors',
|
||||
title.trim() && !isSubmitting
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add task'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/Layout.tsx
Normal file
41
apps/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
|
||||
export function Layout() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const { fetchProjects, fetchLabels } = useTasksStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchProjects();
|
||||
fetchLabels();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/web/src/components/Sidebar.tsx
Normal file
185
apps/web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||
Hash, Settings, LogOut, User, FolderPlus, Tag
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { projects, labels } = useTasksStore();
|
||||
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
||||
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const inbox = projects.find(p => p.isInbox);
|
||||
const regularProjects = projects.filter(p => !p.isInbox);
|
||||
|
||||
const navItems = [
|
||||
{ path: '/inbox', icon: Inbox, label: 'Inbox', color: '#3b82f6' },
|
||||
{ path: '/today', icon: Calendar, label: 'Today', color: '#22c55e' },
|
||||
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||
{/* User section */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
{/* Main nav items */}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
location.pathname === item.path
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-4 h-4" style={{ color: item.color }} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Projects section */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setProjectsExpanded(!projectsExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>Projects</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Link>
|
||||
{projectsExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{projectsExpanded && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{regularProjects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/project/${project.id}`}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
location.pathname === `/project/${project.id}`
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
<span className="truncate">{project.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels section */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>Labels</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to="/labels/new"
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Link>
|
||||
{labelsExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{labelsExpanded && labels.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{labels.map((label) => (
|
||||
<Link
|
||||
key={label.id}
|
||||
to={`/label/${label.id}`}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
location.pathname === `/label/${label.id}`
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3 h-3" style={{ color: label.color }} />
|
||||
<span className="truncate">{label.name}</span>
|
||||
{label.taskCount !== undefined && label.taskCount > 0 && (
|
||||
<span className="ml-auto text-xs text-gray-400">{label.taskCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="p-2 border-t border-gray-200">
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/components/TaskItem.tsx
Normal file
137
apps/web/src/components/TaskItem.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Calendar, Flag, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import type { Task } from '@/types';
|
||||
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
onClick?: () => void;
|
||||
showProject?: boolean;
|
||||
}
|
||||
|
||||
export function TaskItem({ task, onClick, showProject = false }: TaskItemProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { toggleComplete } = useTasksStore();
|
||||
|
||||
const handleComplete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await toggleComplete(task.id);
|
||||
};
|
||||
|
||||
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-start gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer',
|
||||
'hover:bg-gray-50',
|
||||
task.isCompleted && 'opacity-60'
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className={cn(
|
||||
'mt-0.5 flex-shrink-0 w-5 h-5 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-3 h-3 text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
'text-sm text-gray-900',
|
||||
task.isCompleted && 'line-through text-gray-500'
|
||||
)}>
|
||||
{task.title}
|
||||
</p>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{task.dueDate && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 text-xs',
|
||||
overdue ? 'text-red-500' : 'text-gray-500'
|
||||
)}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(task.dueDate)}
|
||||
{task.dueTime && ` ${task.dueTime}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showProject && task.project && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: task.project.color }}
|
||||
/>
|
||||
{task.project.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.taskLabels && task.taskLabels.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{task.taskLabels.slice(0, 2).map(({ label }) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="px-1.5 py-0.5 text-xs rounded"
|
||||
style={{
|
||||
backgroundColor: `${label.color}20`,
|
||||
color: label.color,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{task.taskLabels.length > 2 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
+{task.taskLabels.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{task.subtasks.filter(s => s.isCompleted).length}/{task.subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority flag */}
|
||||
{task.priority !== 'p4' && (
|
||||
<Flag
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: getPriorityColor(task.priority) }}
|
||||
fill={getPriorityColor(task.priority)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions (shown on hover) */}
|
||||
{isHovered && (
|
||||
<button className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user