Add responsive mobile layout with bottom tab navigation
Convert sidebar to fixed bottom tab bar on mobile (<768px) with icon-only nav items and active indicator. Todo list uses stacked card layout on mobile instead of 5-column grid. Reduce padding throughout for small screens and enable user scaling for accessibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,8 @@ export const viewport: Viewport = {
|
||||
themeColor: "#2563EB",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
|
||||
@@ -219,7 +219,7 @@ const RemindersPage = () => {
|
||||
<Label htmlFor="showAdvanceReminder">{t("enableAdvanceReminder")}</Label>
|
||||
</div>
|
||||
{showAdvanceReminder && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<div className="ml-4 md:ml-6 space-y-2">
|
||||
<Label htmlFor="offset">{t("advanceReminder")}</Label>
|
||||
<Input
|
||||
id="offset"
|
||||
@@ -311,7 +311,7 @@ const RemindersPage = () => {
|
||||
<Label htmlFor="showBarkSettings">{t("barkSettings")}</Label>
|
||||
</div>
|
||||
{showBarkSettings && (
|
||||
<div className="ml-6 space-y-4 rounded-xl border border-border/60 bg-muted/30 p-4">
|
||||
<div className="ml-4 md:ml-6 space-y-4 rounded-xl border border-border/60 bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">{t("barkSettingsDesc")}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -271,8 +271,8 @@ const TodosPage = () => {
|
||||
<CardContent>
|
||||
{todos.length > 0 ? (
|
||||
<div className="divide-y divide-border/50">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 pb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{/* Table header - desktop only */}
|
||||
<div className="hidden md:grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 pb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<div className="w-5" />
|
||||
<div>{t("title")}</div>
|
||||
<div>{t("due")}</div>
|
||||
@@ -284,12 +284,14 @@ const TodosPage = () => {
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="group grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 py-3 transition-colors hover:bg-muted/40 rounded-lg"
|
||||
className="group px-3 py-3 transition-colors hover:bg-muted/40 rounded-lg md:grid md:grid-cols-[auto_1fr_auto_auto_auto] md:items-center md:gap-4"
|
||||
>
|
||||
{/* Mobile: Row 1 - Checkbox + Title */}
|
||||
<div className="flex items-center gap-3 md:contents">
|
||||
{/* Checkbox / Check-in */}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
todo.isCheckedIn
|
||||
? "border-emerald-500 bg-emerald-500 text-white"
|
||||
: "border-border hover:border-emerald-400"
|
||||
@@ -301,8 +303,8 @@ const TodosPage = () => {
|
||||
</button>
|
||||
|
||||
{/* Title + badges */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`truncate text-sm font-medium ${todo.isCheckedIn ? "text-muted-foreground line-through" : "text-foreground"}`}>
|
||||
{todo.title}
|
||||
</span>
|
||||
@@ -319,7 +321,10 @@ const TodosPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Row 2 - Due date + Status + Delete */}
|
||||
<div className="flex items-center gap-2 mt-2 pl-8 md:contents md:mt-0 md:pl-0">
|
||||
{/* Due date */}
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDueDate(todo.dueAt)}
|
||||
@@ -331,12 +336,12 @@ const TodosPage = () => {
|
||||
</span>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex justify-end w-16">
|
||||
<div className="flex justify-end ml-auto md:w-16">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
className="rounded-lg p-1.5 text-muted-foreground opacity-100 md:opacity-0 transition-all hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -361,6 +366,7 @@ const TodosPage = () => {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -384,37 +390,37 @@ const TodosPage = () => {
|
||||
{/* Right column - Stats & Upcoming */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<div className="grid grid-cols-3 gap-2 md:grid-cols-1 md:gap-3">
|
||||
<Card className="p-3 md:p-4">
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
|
||||
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{totalTasks}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("totalTasks")}</div>
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-xl md:text-2xl font-semibold text-foreground">{totalTasks}</div>
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground">{t("totalTasks")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600">
|
||||
<Card className="p-3 md:p-4">
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
|
||||
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600">
|
||||
<CalendarCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{checkedInCount}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("checkedInToday")}</div>
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-xl md:text-2xl font-semibold text-foreground">{checkedInCount}</div>
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground">{t("checkedInToday")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600">
|
||||
<Card className="p-3 md:p-4">
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
|
||||
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600">
|
||||
<Repeat className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{recurringCount}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("recurringTasks")}</div>
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-xl md:text-2xl font-semibold text-foreground">{recurringCount}</div>
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground">{t("recurringTasks")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -10,10 +10,10 @@ import { UserProvider } from "@/lib/user-context";
|
||||
const AppShellContent = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<SidebarProvider defaultOpen>
|
||||
<div className="flex min-h-screen bg-muted/50 p-6">
|
||||
<div className="flex min-h-screen bg-muted/50 p-2 md:p-6">
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div className="w-full max-w-[1200px] py-6 px-6">
|
||||
<div className="w-full max-w-[1200px] py-3 px-3 md:py-6 md:px-6">
|
||||
<div className="grid gap-5">{children}</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import Avatar from "@/components/ui/avatar";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
@@ -37,6 +38,42 @@ const AppSidebar = () => {
|
||||
const { unreadCount } = useNotification();
|
||||
const { user } = useUser();
|
||||
const t = useTranslation();
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sidebar>
|
||||
<nav className="flex items-center justify-around px-1 py-1.5 safe-bottom">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname?.startsWith(item.href);
|
||||
const isNotifications = item.href === "/notifications";
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isNotifications && unreadCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1 h-2 w-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-1 h-0.5 w-4 rounded-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" className="h-[calc(100vh-3rem)] self-start bg-white">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
@@ -8,6 +10,7 @@ type SidebarContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
toggle: () => void;
|
||||
isMobile: boolean;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextValue | null>(null);
|
||||
@@ -20,6 +23,8 @@ const useSidebar = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const SidebarProvider = ({
|
||||
children,
|
||||
defaultOpen = true,
|
||||
@@ -28,9 +33,21 @@ const SidebarProvider = ({
|
||||
defaultOpen?: boolean;
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
const toggle = React.useCallback(() => setOpen((prev) => !prev), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
setIsMobile(e.matches);
|
||||
};
|
||||
onChange(mql);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ open, setOpen, toggle }}>
|
||||
<SidebarContext.Provider value={{ open, setOpen, toggle, isMobile }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
@@ -57,8 +74,24 @@ const sidebarVariants = cva(
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof sidebarVariants>
|
||||
>(({ className, variant, style, ...props }, ref) => {
|
||||
const { open } = useSidebar();
|
||||
>(({ className, variant, style, children, ...props }, ref) => {
|
||||
const { open, isMobile } = useSidebar();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50 border-t border-border/40 bg-white",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -78,9 +111,16 @@ const Sidebar = React.forwardRef<
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("min-h-screen flex-1", className)} {...props} />
|
||||
)
|
||||
({ className, ...props }, ref) => {
|
||||
const { isMobile } = useSidebar();
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("min-h-screen flex-1", isMobile && "pb-16", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user