feat: mobile-responsive layout with collapsible sidebar
This commit is contained in:
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{/* Desktop sidebar — always visible on md+ */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto p-8">
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetail
|
<TaskDetail
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -172,6 +257,7 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user