Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Dong
6f33c71240 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>
2026-02-27 13:55:15 +08:00
6 changed files with 182 additions and 99 deletions

View File

@@ -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 = {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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";