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="description" content="Todo App - Task management made simple" />
|
||||
<title>Todo App</title>
|
||||
<script type="module" crossorigin src="/assets/index-D6mF4afG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XdXarncg.css">
|
||||
<script type="module" crossorigin src="/assets/index-BYeSixg2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 { Menu } from 'lucide-react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TaskDetail } from './TaskDetail';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -8,6 +9,7 @@ import { useTasksStore } from '@/stores/tasks';
|
||||
export function Layout() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const { fetchProjects, fetchLabels, selectedTask, setSelectedTask } = useTasksStore();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
@@ -33,10 +35,34 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{/* Desktop sidebar — always visible on md+ */}
|
||||
<div className="hidden md:block">
|
||||
<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 />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetail
|
||||
task={selectedTask}
|
||||
|
||||
@@ -16,7 +16,13 @@ const PROJECT_COLORS = [
|
||||
|
||||
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 navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
@@ -36,6 +42,9 @@ export function Sidebar() {
|
||||
});
|
||||
const newProjectInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Determine if this is the mobile overlay instance
|
||||
const isMobileInstance = mobileOpen !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewProject && newProjectInputRef.current) {
|
||||
newProjectInputRef.current.focus();
|
||||
@@ -50,6 +59,13 @@ export function Sidebar() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile sidebar when a nav item is clicked
|
||||
if (isMobileInstance && onMobileClose) {
|
||||
onMobileClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName.trim() || isCreatingProject) return;
|
||||
setIsCreatingProject(true);
|
||||
@@ -59,6 +75,7 @@ export function Sidebar() {
|
||||
setNewProjectColor(PROJECT_COLORS[0]);
|
||||
setShowNewProject(false);
|
||||
navigate(`/project/${project.id}`);
|
||||
handleNavClick();
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
} finally {
|
||||
@@ -86,7 +103,75 @@ export function Sidebar() {
|
||||
{ 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) {
|
||||
return (
|
||||
<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 (
|
||||
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||
{/* User section */}
|
||||
@@ -196,12 +282,42 @@ export function Sidebar() {
|
||||
|
||||
{/* 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"
|
||||
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 */}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
location.pathname === item.path
|
||||
@@ -247,6 +363,7 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/project/${project.id}`}
|
||||
onClick={handleNavClick}
|
||||
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`
|
||||
@@ -333,7 +450,10 @@ export function Sidebar() {
|
||||
<Link
|
||||
to="/labels/new"
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNavClick();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Link>
|
||||
@@ -351,6 +471,7 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={label.id}
|
||||
to={`/label/${label.id}`}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
location.pathname === `/label/${label.id}`
|
||||
@@ -368,27 +489,7 @@ export function Sidebar() {
|
||||
</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 */}
|
||||
<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"
|
||||
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 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Disable automatic dark mode - force light mode */
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-dark: #2563eb;
|
||||
--color-danger: #ef4444;
|
||||
|
||||
@@ -173,8 +173,8 @@ export function AdminPage() {
|
||||
<div className="text-center py-12 text-gray-500">Loading...</div>
|
||||
) : activeTab === 'users' ? (
|
||||
/* Users list */
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<table className="w-full">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-sm text-gray-500">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
@@ -311,7 +311,7 @@ export function AdminPage() {
|
||||
{inviteError && (
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
@@ -360,8 +360,8 @@ export function AdminPage() {
|
||||
)}
|
||||
|
||||
{/* Invites list */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<table className="w-full">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-sm text-gray-500">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
|
||||
@@ -98,8 +98,8 @@ export function ProjectPage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
||||
<span
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: project.color }}
|
||||
|
||||
Reference in New Issue
Block a user