feat: mobile-responsive layout with collapsible sidebar

This commit is contained in:
2026-01-28 19:53:02 +00:00
parent d9bcbd9701
commit 6f26e1117c
7 changed files with 173 additions and 42 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="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" /> <meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title> <title>Todo App</title>
<script type="module" crossorigin src="/assets/index-D6mF4afG.js"></script> <script type="module" crossorigin src="/assets/index-BYeSixg2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XdXarncg.css"> <link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom'; import { Outlet, Navigate } from 'react-router-dom';
import { Menu } from 'lucide-react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { TaskDetail } from './TaskDetail'; import { TaskDetail } from './TaskDetail';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -8,6 +9,7 @@ import { useTasksStore } from '@/stores/tasks';
export function Layout() { export function Layout() {
const { isAuthenticated, isLoading } = useAuthStore(); const { isAuthenticated, isLoading } = useAuthStore();
const { fetchProjects, fetchLabels, selectedTask, setSelectedTask } = useTasksStore(); const { fetchProjects, fetchLabels, selectedTask, setSelectedTask } = useTasksStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
@@ -33,10 +35,34 @@ export function Layout() {
return ( return (
<div className="flex h-screen bg-white"> <div className="flex h-screen bg-white">
<Sidebar /> {/* Desktop sidebar — always visible on md+ */}
<main className="flex-1 overflow-y-auto p-8"> <div className="hidden md:block">
<Outlet /> <Sidebar />
</main> </div>
{/* Mobile sidebar — overlay drawer */}
<Sidebar
mobileOpen={sidebarOpen}
onMobileClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */}
<div className="md:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-200 bg-white">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold text-gray-900">Todo App</h1>
</div>
<main className="flex-1 overflow-y-auto p-4 md:p-8">
<Outlet />
</main>
</div>
{selectedTask && ( {selectedTask && (
<TaskDetail <TaskDetail
task={selectedTask} task={selectedTask}

View File

@@ -16,7 +16,13 @@ const PROJECT_COLORS = [
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed'; const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
export function Sidebar() { interface SidebarProps {
/** When defined, this instance is the mobile overlay drawer */
mobileOpen?: boolean;
onMobileClose?: () => void;
}
export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@@ -36,6 +42,9 @@ export function Sidebar() {
}); });
const newProjectInputRef = useRef<HTMLInputElement>(null); const newProjectInputRef = useRef<HTMLInputElement>(null);
// Determine if this is the mobile overlay instance
const isMobileInstance = mobileOpen !== undefined;
useEffect(() => { useEffect(() => {
if (showNewProject && newProjectInputRef.current) { if (showNewProject && newProjectInputRef.current) {
newProjectInputRef.current.focus(); newProjectInputRef.current.focus();
@@ -50,6 +59,13 @@ export function Sidebar() {
} catch {} } catch {}
}; };
const handleNavClick = () => {
// Close mobile sidebar when a nav item is clicked
if (isMobileInstance && onMobileClose) {
onMobileClose();
}
};
const handleCreateProject = async () => { const handleCreateProject = async () => {
if (!newProjectName.trim() || isCreatingProject) return; if (!newProjectName.trim() || isCreatingProject) return;
setIsCreatingProject(true); setIsCreatingProject(true);
@@ -59,6 +75,7 @@ export function Sidebar() {
setNewProjectColor(PROJECT_COLORS[0]); setNewProjectColor(PROJECT_COLORS[0]);
setShowNewProject(false); setShowNewProject(false);
navigate(`/project/${project.id}`); navigate(`/project/${project.id}`);
handleNavClick();
} catch (error) { } catch (error) {
console.error('Failed to create project:', error); console.error('Failed to create project:', error);
} finally { } finally {
@@ -86,7 +103,75 @@ export function Sidebar() {
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' }, { path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
]; ];
// Collapsed sidebar // Mobile overlay instance
if (isMobileInstance) {
return (
<>
{/* Backdrop */}
<div
className={cn(
'md:hidden fixed inset-0 bg-black/40 z-40 transition-opacity duration-200',
mobileOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onMobileClose}
/>
{/* Drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-gray-50 flex flex-col shadow-xl transition-transform duration-200 ease-out',
mobileOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Close button + 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={onMobileClose}
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>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-2">
{renderNavContent()}
</nav>
{/* Bottom section */}
<div className="p-2 border-t border-gray-200">
{user?.role === 'admin' && (
<Link
to="/admin"
onClick={handleNavClick}
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>
</>
);
}
// Desktop: Collapsed sidebar
if (isCollapsed) { if (isCollapsed) {
return ( return (
<aside className="w-14 h-screen bg-gray-50 border-r border-gray-200 flex flex-col items-center"> <aside className="w-14 h-screen bg-gray-50 border-r border-gray-200 flex flex-col items-center">
@@ -171,7 +256,8 @@ export function Sidebar() {
</aside> </aside>
); );
} }
// Desktop: Expanded sidebar
return ( return (
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col"> <aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
{/* User section */} {/* User section */}
@@ -196,12 +282,42 @@ export function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto p-2"> <nav className="flex-1 overflow-y-auto p-2">
{renderNavContent()}
</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>
);
// Shared nav content for both desktop expanded and mobile drawer
function renderNavContent() {
return (
<>
{/* Main nav items */} {/* Main nav items */}
<div className="space-y-1"> <div className="space-y-1">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={handleNavClick}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
location.pathname === item.path location.pathname === item.path
@@ -247,6 +363,7 @@ export function Sidebar() {
<Link <Link
key={project.id} key={project.id}
to={`/project/${project.id}`} to={`/project/${project.id}`}
onClick={handleNavClick}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors', '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` location.pathname === `/project/${project.id}` || location.pathname === `/project/${project.id}/board`
@@ -333,7 +450,10 @@ export function Sidebar() {
<Link <Link
to="/labels/new" to="/labels/new"
className="p-1 hover:bg-gray-200 rounded" className="p-1 hover:bg-gray-200 rounded"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation();
handleNavClick();
}}
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</Link> </Link>
@@ -351,6 +471,7 @@ export function Sidebar() {
<Link <Link
key={label.id} key={label.id}
to={`/label/${label.id}`} to={`/label/${label.id}`}
onClick={handleNavClick}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
location.pathname === `/label/${label.id}` location.pathname === `/label/${label.id}`
@@ -368,27 +489,7 @@ export function Sidebar() {
</div> </div>
)} )}
</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>
);
} }

View File

@@ -145,7 +145,7 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
{/* Panel */} {/* Panel */}
<div <div
ref={panelRef} 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" className="fixed right-0 top-0 h-full w-full max-w-lg sm:max-w-lg bg-white shadow-xl z-50 flex flex-col animate-slide-in overflow-y-auto"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">

View File

@@ -1,6 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
/* Disable automatic dark mode - force light mode */
@custom-variant dark (&:is(.dark *));
:root { :root {
color-scheme: light;
--color-primary: #3b82f6; --color-primary: #3b82f6;
--color-primary-dark: #2563eb; --color-primary-dark: #2563eb;
--color-danger: #ef4444; --color-danger: #ef4444;

View File

@@ -173,8 +173,8 @@ export function AdminPage() {
<div className="text-center py-12 text-gray-500">Loading...</div> <div className="text-center py-12 text-gray-500">Loading...</div>
) : activeTab === 'users' ? ( ) : activeTab === 'users' ? (
/* Users list */ /* Users list */
<div className="bg-white rounded-lg border border-gray-200"> <div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
<table className="w-full"> <table className="w-full min-w-[600px]">
<thead> <thead>
<tr className="border-b border-gray-200 text-left text-sm text-gray-500"> <tr className="border-b border-gray-200 text-left text-sm text-gray-500">
<th className="px-4 py-3 font-medium">Name</th> <th className="px-4 py-3 font-medium">Name</th>
@@ -311,7 +311,7 @@ export function AdminPage() {
{inviteError && ( {inviteError && (
<p className="text-sm text-red-500">{inviteError}</p> <p className="text-sm text-red-500">{inviteError}</p>
)} )}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label> <label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input <input
@@ -360,8 +360,8 @@ export function AdminPage() {
)} )}
{/* Invites list */} {/* Invites list */}
<div className="bg-white rounded-lg border border-gray-200"> <div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
<table className="w-full"> <table className="w-full min-w-[600px]">
<thead> <thead>
<tr className="border-b border-gray-200 text-left text-sm text-gray-500"> <tr className="border-b border-gray-200 text-left text-sm text-gray-500">
<th className="px-4 py-3 font-medium">Name</th> <th className="px-4 py-3 font-medium">Name</th>

View File

@@ -98,8 +98,8 @@ export function ProjectPage() {
return ( return (
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0 flex-wrap">
<span <span
className="w-4 h-4 rounded" className="w-4 h-4 rounded"
style={{ backgroundColor: project.color }} style={{ backgroundColor: project.color }}