395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
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,
|
|
PanelLeftClose, PanelLeftOpen
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useTasksStore } from '@/stores/tasks';
|
|
|
|
const PROJECT_COLORS = [
|
|
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
|
|
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
|
];
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
|
|
|
|
export function Sidebar() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { user, logout } = useAuthStore();
|
|
const { projects, labels, createProject } = useTasksStore();
|
|
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
|
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
|
const [showNewProject, setShowNewProject] = useState(false);
|
|
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(() => {
|
|
if (showNewProject && newProjectInputRef.current) {
|
|
newProjectInputRef.current.focus();
|
|
}
|
|
}, [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);
|
|
try {
|
|
const project = await createProject({ name: newProjectName.trim(), color: newProjectColor });
|
|
setNewProjectName('');
|
|
setNewProjectColor(PROJECT_COLORS[0]);
|
|
setShowNewProject(false);
|
|
navigate(`/project/${project.id}`);
|
|
} catch (error) {
|
|
console.error('Failed to create project:', error);
|
|
} finally {
|
|
setIsCreatingProject(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelNewProject = () => {
|
|
setShowNewProject(false);
|
|
setNewProjectName('');
|
|
setNewProjectColor(PROJECT_COLORS[0]);
|
|
};
|
|
|
|
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' },
|
|
];
|
|
|
|
// 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 */}
|
|
<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>
|
|
<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>
|
|
|
|
{/* 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">
|
|
<button
|
|
className="p-1 hover:bg-gray-200 rounded"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowNewProject(true);
|
|
setProjectsExpanded(true);
|
|
}}
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
</button>
|
|
{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}` || location.pathname === `/project/${project.id}/board`
|
|
? '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>
|
|
))}
|
|
|
|
{/* Inline create project form */}
|
|
{showNewProject && (
|
|
<div className="px-2 py-2">
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleCreateProject();
|
|
}}
|
|
className="border border-gray-200 rounded-lg p-2 bg-white shadow-sm space-y-2"
|
|
>
|
|
<input
|
|
ref={newProjectInputRef}
|
|
type="text"
|
|
value={newProjectName}
|
|
onChange={(e) => setNewProjectName(e.target.value)}
|
|
placeholder="Project name"
|
|
className="w-full text-sm text-gray-900 placeholder-gray-400 border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400"
|
|
/>
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{PROJECT_COLORS.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
onClick={() => setNewProjectColor(color)}
|
|
className={cn(
|
|
'w-5 h-5 rounded-full border-2 transition-all',
|
|
newProjectColor === color ? 'border-gray-700 scale-110' : 'border-transparent'
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleCancelNewProject}
|
|
className="px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!newProjectName.trim() || isCreatingProject}
|
|
className={cn(
|
|
'px-2 py-1 text-xs font-medium rounded',
|
|
newProjectName.trim() && !isCreatingProject
|
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isCreatingProject ? 'Adding...' : 'Add'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|