Add responsive mobile layout for the app shell and board #6

Merged
brianfertig merged 1 commits from Mobile into main 2026-08-30 04:43:16 +00:00
23 changed files with 551 additions and 54 deletions

136
.cdp/mobile-shots.cjs Normal file
View File

@ -0,0 +1,136 @@
/* Mobile responsive verification: login, then screenshot the app at a
phone viewport (390x844) in the key mobile states, plus a desktop
sanity shot. Uses the Playwright-cached chromium headless shell. */
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const path = require("node:path");
const CHROME = "/home/brianfertig/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome";
const PORT = 9351;
const BASE = "http://localhost:3000";
const OUT = path.join(__dirname, "..", "shots");
function httpJson(p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
let d = ""; res.on("data", (c) => (d += c));
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
});
req.on("error", reject); req.end();
});
}
(async () => {
const profile = fs.mkdtempSync("/tmp/chrome-prof-");
const proc = spawn(CHROME, ["--headless=new", "--no-sandbox", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
let targets = null;
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
if (!targets) { console.error("no targets"); proc.kill(); process.exit(1); }
const page = targets.find((t) => t.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const send = (method, params = {}) => new Promise((resolve) => {
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
});
ws._buf = [];
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
if (!/hydrated|React DevTools|HMR|404/.test(txt)) console.log("[err]", txt.slice(0, 160));
} };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function shot(name, w, h, mobile) {
await send("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: 1, mobile, hasTouch: mobile });
const s = await send("Page.captureScreenshot", { format: "png" });
fs.writeFileSync(path.join(OUT, `${name}.png`), Buffer.from(s.data, "base64"));
console.log(`saved ${name} (${w}x${h})`);
}
async function click(css) {
const res = await send("Runtime.evaluate", { expression: `(() => { const el = document.querySelector(${JSON.stringify(css)}); if (!el) return "missing"; el.click(); return "clicked"; })()`, returnByValue: true });
console.log(`click(${css}) ->`, res?.result?.value ?? res);
}
async function evalJs(expr) {
return (await send("Runtime.evaluate", { expression: expr, returnByValue: true }))?.result?.value;
}
await send("Runtime.enable"); await send("Page.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, hasTouch: true });
// ---- login (phone viewport) ----
await send("Page.navigate", { url: `${BASE}/login` });
await wait(3000);
const loginRes = await evalJs(`(() => {
const setVal = (el, v) => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
setter.call(el, v);
el.dispatchEvent(new Event("input", { bubbles: true }));
};
const email = document.querySelector("#email");
const password = document.querySelector("#password");
if (!email || !password) return "inputs missing";
setVal(email, "dev@example.com");
setVal(password, "password123");
email.form.requestSubmit();
return "submitted";
})()`);
console.log("login ->", loginRes);
await wait(4000);
console.log("now at", await evalJs("location.href"));
// ---- 1. home board, mobile ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(3500);
await shot("m01-home-board", 390, 844, true);
// ---- 2. nav drawer open ----
await click('button[aria-label="Open menu"]');
await wait(900);
await shot("m02-home-drawer", 390, 844, true);
// close via backdrop: click the backdrop element (base-ui renders it as a button/div behind)
await evalJs(`(() => { const b = document.querySelector('[data-slot="dialog-overlay"], .fixed.inset-0.z-40'); if (b) b.click(); const pop = document.querySelector('[data-slot="dialog-content"]'); return "done"; })()`);
await wait(500);
// ensure closed: dispatch Escape
await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await wait(600);
// ---- 3. second lane via chip ----
const chips = await evalJs(`Array.from(document.querySelectorAll("button[aria-pressed]")).map(b => b.textContent).join(" | ")`);
console.log("chips:", chips);
await click('button[aria-pressed="false"]');
await wait(1200);
await shot("m03-home-lane2", 390, 844, true);
// ---- 4. scheduled sheet ----
const dock = await evalJs(`(() => { const b = Array.from(document.querySelectorAll("nav[aria-label='Scheduled to-dos'] button")).pop(); if (!b) return "missing"; b.click(); return "clicked"; })()`);
console.log("dock ->", dock);
await wait(1000);
await shot("m04-scheduled-sheet", 390, 844, true);
await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await wait(600);
// ---- 5. projects page ----
await send("Page.navigate", { url: `${BASE}/projects` });
await wait(2500);
await shot("m05-projects", 390, 844, true);
// ---- 6. chat page ----
await send("Page.navigate", { url: `${BASE}/chat` });
await wait(2500);
await shot("m06-chat", 390, 844, true);
// ---- 7. tablet width (768, desktop shell boundary) ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(2500);
await shot("m07-tablet-768", 768, 1024, false);
// ---- 8. desktop sanity ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(2500);
await shot("m08-desktop-1440", 1440, 900, false);
proc.kill(); process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });

View File

@ -6,6 +6,7 @@ import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project"; import type { ProjectDTO } from "@/types/project";
import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNavProvider } from "@/components/nav/side-nav-provider";
import { SideNav } from "@/components/nav/side-nav"; import { SideNav } from "@/components/nav/side-nav";
import { MobileTopBar } from "@/components/nav/mobile-top-bar";
import { ProjectsProvider } from "@/components/projects/projects-context"; import { ProjectsProvider } from "@/components/projects/projects-context";
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider"; import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel"; import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
@ -40,13 +41,18 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<ProjectsProvider initialProjects={projects}> <ProjectsProvider initialProjects={projects}>
<BoardViewProvider> <BoardViewProvider>
<HoldOnCompleteProvider> <HoldOnCompleteProvider>
{/* On small screens the three-column shell would crush the {/* Responsive shell:
board; give it a sensible minimum width and let the page - md and up: the original three-column desktop layout, with
scroll horizontally instead, and drop the Scheduled panel the same min-width + horizontal-scroll fallback as before.
(auxiliary on a phone) until the viewport can hold it. */} - below md (phones): a true stacked app layout -- top bar,
<div className="flex h-screen min-w-[860px] overflow-x-auto"> full-height board, and the Scheduled dock (rendered inside
ScheduledPanel) take the full column with no horizontal
scrolling; h-dvh tracks the mobile browser's dynamic
toolbar instead of the stale 100vh. */}
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
<MobileTopBar />
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="min-h-0 flex-1 overflow-auto">{children}</main>
<ScheduledPanel /> <ScheduledPanel />
</div> </div>
</HoldOnCompleteProvider> </HoldOnCompleteProvider>

View File

@ -49,8 +49,8 @@ export function AdminUserTable({
return ( return (
<> <>
<div className="max-w-3xl overflow-hidden rounded-xl border"> <div className="max-w-3xl overflow-x-auto rounded-xl border">
<table className="w-full text-sm"> <table className="w-full min-w-[560px] text-sm">
<thead className="bg-muted/40 text-left text-muted-foreground"> <thead className="bg-muted/40 text-left text-muted-foreground">
<tr> <tr>
<th className="px-4 py-2 font-medium">Name</th> <th className="px-4 py-2 font-medium">Name</th>

View File

@ -39,7 +39,10 @@ export function AddCategoryLane() {
render={ render={
<button <button
data-board-chrome="" data-board-chrome=""
className="flex h-full min-h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary" // Mobile: a full-width snap page below the lanes (stacked under
// the EmptyState, full-width next to them); desktop: the usual
// dashed lane at the end of the row.
className="flex h-40 w-full shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary snap-start md:h-full md:min-h-32 md:w-72"
aria-label="Add category" aria-label="Add category"
> >
<span className="flex size-9 items-center justify-center rounded-full bg-foreground/5"> <span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">

View File

@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { CategoryDTO } from "@/types/board";
/**
* Mobile (< md) category rail: one chip per lane, shown under the top bar.
* Doubles as the board's position indicator while swiping between lanes
* (the active chip is highlighted, driven by the board's
* IntersectionObserver) and as quick navigation -- tapping a chip glides
* the lane pager to that lane.
*/
export function CategoryChips({
categories,
activeCategoryId,
onSelect,
}: {
categories: CategoryDTO[];
activeCategoryId: string | null;
onSelect: (id: string) => void;
}) {
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
// Keep the active chip in view if the user swipes several lanes at once
// (or a chip gets added/removed out of the strip's view).
useEffect(() => {
if (!activeCategoryId) return;
chipRefs.current
.get(activeCategoryId)
?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
}, [activeCategoryId]);
return (
<div
// -mx-3/px-3 bleeds the strip to the screen edges (the board's own
// p-3) so chips start and end flush with the viewport, the way a
// native tab strip does.
className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 overscroll-x-contain"
>
{categories.map((category) => {
const active = category.id === activeCategoryId;
const openTodos = category.groups.reduce(
(n, g) => n + g.todos.filter((t) => !t.completed).length,
0
);
return (
<button
key={category.id}
ref={(el) => {
if (el) chipRefs.current.set(category.id, el);
else chipRefs.current.delete(category.id);
}}
type="button"
onClick={() => onSelect(category.id)}
aria-pressed={active}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors",
active
? "border-transparent bg-primary text-primary-foreground shadow-sm"
: "border-border bg-lane text-muted-foreground active:bg-accent active:text-accent-foreground"
)}
>
{category.name}
<span
className={cn(
"rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
active ? "bg-primary-foreground/20" : "bg-foreground/8"
)}
aria-label={`${openTodos} open to-dos`}
>
{openTodos}
</span>
</button>
);
})}
</div>
);
}

View File

@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core"; import { useDndContext, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react"; import { GripVertical, MoreVertical, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -98,9 +98,13 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
<div <div
ref={setNodeRef} ref={setNodeRef}
data-category-lane="" data-category-lane=""
data-category-id={category.id}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn( className={cn(
"flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane", // w-full on mobile: the lane pager gives each lane the whole
// screen width (one lane per swipe, see kanban-board's pager);
// w-72 side-by-side at md and up.
"flex h-full w-full shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane snap-start md:w-72",
isDragging && "opacity-50" isDragging && "opacity-50"
)} )}
> >

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@ -17,6 +17,7 @@ import { LayoutDashboard, Plus } from "lucide-react";
import { BoardProvider, useBoard } from "@/components/board/board-context"; import { BoardProvider, useBoard } from "@/components/board/board-context";
import { CategoryLane } from "@/components/board/category-lane"; import { CategoryLane } from "@/components/board/category-lane";
import { CategoryChips } from "@/components/board/category-chips";
import { AddCategoryLane } from "@/components/board/add-category-lane"; import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state"; import { EmptyState } from "@/components/board/empty-state";
@ -40,6 +41,11 @@ function Board({
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null); const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false); const [quickAddOpen, setQuickAddOpen] = useState(false);
const pagerRef = useRef<HTMLDivElement>(null);
// Which lane is front-and-center in the mobile pager (see the observer
// below) -- null until the first intersection report lands, after which
// CategoryChips falls back to the first lane for the brief gap.
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
// The "+ to-do" quick action targets the first group it can find -- the // The "+ to-do" quick action targets the first group it can find -- the
// goal is one click from a keyboard or pointer to start typing a to-do // goal is one click from a keyboard or pointer to start typing a to-do
@ -129,6 +135,51 @@ function Board({
const categoryIds = categories.map((c) => c.id); const categoryIds = categories.map((c) => c.id);
// Mobile pager awareness: watch the lanes inside the scroll pager and
// keep the chip rail in sync with whichever lane is most visible. Only
// runs when lanes actually exist; re-runs when the lane set changes so
// added/removed lanes get observed. (No-op on desktop -- the lanes are
// there, but the chips are hidden, so the state is simply unused.)
useEffect(() => {
const pager = pagerRef.current;
if (!pager) return;
const lanes = Array.from(pager.querySelectorAll<HTMLElement>("[data-category-id]"));
if (lanes.length === 0) return;
const ratios = new Map<HTMLElement, number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const el = entry.target as HTMLElement;
if (entry.isIntersecting) ratios.set(el, entry.intersectionRatio);
else ratios.delete(el);
}
let best: HTMLElement | null = null;
let bestRatio = 0;
for (const lane of lanes) {
const ratio = ratios.get(lane) ?? 0;
if (ratio > bestRatio) {
bestRatio = ratio;
best = lane;
}
}
if (best?.dataset.categoryId) setActiveCategoryId(best.dataset.categoryId);
},
{ root: pager, threshold: [0.25, 0.5, 0.75, 1] }
);
lanes.forEach((lane) => observer.observe(lane));
return () => observer.disconnect();
}, [categories]);
// Chip tap: glide the pager to that lane (snap then settles it exactly
// onto the lane's start edge).
function handleSelectChip(id: string) {
const pager = pagerRef.current;
const lane = pager?.querySelector<HTMLElement>(`[data-category-id="${id}"]`);
if (!pager || !lane) return;
const left = lane.getBoundingClientRect().left - pager.getBoundingClientRect().left + pager.scrollLeft;
pager.scrollTo({ left, behavior: "smooth" });
}
return ( return (
<DndContext <DndContext
id="board-dnd" id="board-dnd"
@ -137,18 +188,18 @@ function Board({
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex h-full flex-col gap-4 p-4"> <div className="flex h-full flex-col gap-3 p-3 md:gap-4 md:p-4">
<div <div
data-board-chrome="" data-board-chrome=""
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1"
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex"> <span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary md:flex">
<LayoutDashboard className="size-5" /> <LayoutDashboard className="size-5" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1> <h1 className="truncate font-heading text-lg font-bold leading-tight md:text-xl">{title}</h1>
{/* Per-project theme picker, to the right of the project's {/* Per-project theme picker, to the right of the project's
name -- only projects have their own theme assignment. */} name -- only projects have their own theme assignment. */}
{projectId && <ProjectThemePicker projectId={projectId} />} {projectId && <ProjectThemePicker projectId={projectId} />}
@ -179,9 +230,10 @@ function Board({
className="gap-1.5" className="gap-1.5"
disabled={!quickTargetGroup} disabled={!quickTargetGroup}
onClick={() => setQuickAddOpen(true)} onClick={() => setQuickAddOpen(true)}
aria-label="Add to-do"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
To-do <span className="hidden md:inline">To-do</span>
</Button> </Button>
} }
/> />
@ -194,12 +246,29 @@ function Board({
</div> </div>
{categories.length === 0 ? ( {categories.length === 0 ? (
<div className="flex flex-1 items-center gap-4 overflow-x-auto"> <div className="flex flex-1 flex-col items-stretch gap-4 overflow-y-auto md:flex-row md:items-center md:overflow-x-auto">
<EmptyState /> <EmptyState />
<AddCategoryLane /> <AddCategoryLane />
</div> </div>
) : ( ) : (
<div className="flex flex-1 items-start gap-4 overflow-x-auto pb-2"> <>
{/* Mobile only: the category rail. On desktop the lanes are
already labeled and side-by-side, so this stays hidden. */}
<div className="md:hidden">
<CategoryChips
categories={categories}
activeCategoryId={activeCategoryId ?? categories[0]?.id ?? null}
onSelect={handleSelectChip}
/>
</div>
{/* Mobile: one lane per screen, snap-paged (swipe = next lane),
while each lane still scrolls its cards vertically. Desktop
keeps the plain side-by-side scroll (snap disabled at md). */}
<div
ref={pagerRef}
className="flex min-h-0 flex-1 items-start gap-4 overflow-x-auto pb-2 snap-x snap-mandatory overscroll-x-contain md:snap-none"
>
<SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}> <SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}>
{categories.map((category) => ( {categories.map((category) => (
<CategoryLane key={category.id} category={category} /> <CategoryLane key={category.id} category={category} />
@ -207,6 +276,7 @@ function Board({
</SortableContext> </SortableContext>
<AddCategoryLane /> <AddCategoryLane />
</div> </div>
</>
)} )}
</div> </div>

View File

@ -151,9 +151,9 @@ export function SummaryButton({ projectId }: { projectId?: string }) {
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button variant="outline" size="sm" className="gap-1.5"> <Button variant="outline" size="sm" className="gap-1.5" aria-label="Summarize completed work">
<FileText className="size-3.5" /> <FileText className="size-3.5" />
Summary <span className="hidden md:inline">Summary</span>
</Button> </Button>
} }
/> />

View File

@ -28,9 +28,9 @@ export function ViewSwitcher() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground"> <Button variant="ghost" size="sm" className="gap-2 text-muted-foreground" aria-label={`Board view: ${current.label}`}>
<Icon className="size-4" /> <Icon className="size-4" />
{current.label} <span className="hidden md:inline">{current.label}</span>
</Button> </Button>
} }
/> />

View File

@ -0,0 +1,36 @@
"use client";
import { Menu, NotebookPen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useSideNav } from "@/components/nav/side-nav-provider";
/**
* Mobile (< md) app top bar: hamburger (opens the nav drawer) + brand,
* with the theme picker on the right so it's reachable without opening
* the drawer. The page's own header (board title, counts, actions) sits
* in the content area just below this bar.
*/
export function MobileTopBar() {
const { setMobileOpen } = useSideNav();
return (
<header className="flex h-14 shrink-0 items-center gap-1.5 border-b bg-background px-2.5 md:hidden">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="Open menu">
<Menu className="size-5" />
</Button>
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<NotebookPen className="size-4" />
</span>
<span className="truncate font-heading text-[15px] font-bold tracking-tight">Organize</span>
</div>
<div className="ml-auto flex shrink-0 items-center">
<ThemeToggle collapsed menuSide="bottom" menuAlign="end" />
</div>
</header>
);
}

View File

@ -7,6 +7,10 @@ const STORAGE_KEY = "organize:sidenav-collapsed";
interface SideNavContextValue { interface SideNavContextValue {
collapsed: boolean; collapsed: boolean;
toggle: () => void; toggle: () => void;
// The mobile (< md) hamburger drawer. Deliberately NOT persisted: a drawer
// should never be left open across a reload or navigation.
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
} }
const SideNavContext = createContext<SideNavContextValue | null>(null); const SideNavContext = createContext<SideNavContextValue | null>(null);
@ -16,6 +20,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
// hydration mismatch; the real persisted value is applied right after // hydration mismatch; the real persisted value is applied right after
// mount, trading a one-frame flash for zero hydration warnings. // mount, trading a one-frame flash for zero hydration warnings.
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@ -31,7 +36,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
}; };
return ( return (
<SideNavContext.Provider value={{ collapsed, toggle }}> <SideNavContext.Provider value={{ collapsed, toggle, mobileOpen, setMobileOpen }}>
{children} {children}
</SideNavContext.Provider> </SideNavContext.Provider>
); );

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react"; import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -13,17 +14,29 @@ import { ThemeToggle } from "@/components/theme/theme-toggle";
import { logout } from "@/lib/actions/auth"; import { logout } from "@/lib/actions/auth";
import { Role } from "@/lib/generated/prisma/enums"; import { Role } from "@/lib/generated/prisma/enums";
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { /**
const { collapsed, toggle } = useSideNav(); * The actual nav content, shared by the desktop aside and the mobile
* hamburger drawer so the two never drift apart. `collapsed` is always
* false in the drawer (a 288px-wide sheet has no room for icon-only mode).
*/
function SideNavContent({
collapsed,
userEmail,
role,
themeMenu,
}: {
collapsed: boolean;
userEmail: string;
role: Role;
// Where the theme picker menu opens from its trigger -- right of the
// trigger in the desktop sidebar, below it in the mobile drawer (a
// right-opening menu would run off the phone's right edge there).
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
}) {
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : []; const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
return ( return (
<aside <>
className={cn(
"flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out",
collapsed ? "w-16" : "w-60"
)}
>
<div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}> <div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm"> <span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<NotebookPen className="size-4.5" /> <NotebookPen className="size-4.5" />
@ -46,7 +59,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</nav> </nav>
<div className="space-y-0.5 px-2.5 pb-2"> <div className="space-y-0.5 px-2.5 pb-2">
<ThemeToggle collapsed={collapsed} /> <ThemeToggle collapsed={collapsed} menuSide={themeMenu.side} menuAlign={themeMenu.align} />
{collapsed ? ( {collapsed ? (
<form action={logout}> <form action={logout}>
@ -79,6 +92,55 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span> <span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
)} )}
</div> </div>
</>
);
}
/**
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
* Tapping any link closes it; the backdrop and Escape close it too.
*/
function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) {
const { mobileOpen, setMobileOpen } = useSideNav();
return (
<DialogPrimitive.Root open={mobileOpen} onOpenChange={setMobileOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-40 bg-black/60 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 md:hidden"
/>
<DialogPrimitive.Popup
onClick={(e) => {
// Close on navigation -- links (nav items, projects) are the only
// things that should dismiss it, not e.g. a theme switch.
if ((e.target as HTMLElement).closest("a")) setMobileOpen(false);
}}
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-sidebar text-sidebar-foreground shadow-xl duration-200 md:hidden",
"data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-left",
"data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-left"
)}
>
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
<SideNavContent collapsed={false} userEmail={userEmail} role={role} themeMenu={{ side: "bottom", align: "end" }} />
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
const { collapsed, toggle } = useSideNav();
return (
<>
<aside
className={cn(
"hidden h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out md:flex",
collapsed ? "w-16" : "w-60"
)}
>
<SideNavContent collapsed={collapsed} userEmail={userEmail} role={role} themeMenu={{ side: "right", align: "start" }} />
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}> <div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar"> <Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
@ -86,5 +148,8 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</Button> </Button>
</div> </div>
</aside> </aside>
<MobileNavDrawer userEmail={userEmail} role={role} />
</>
); );
} }

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatWeekdayDate } from "@/lib/format"; import { formatWeekdayDate } from "@/lib/format";
@ -102,6 +103,9 @@ export function ScheduledPanel() {
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null); const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
// Mobile bottom sheet (the dock bar below opens it). Kept separate from
// `dialogOpen` -- that one is the add/edit form dialog.
const [sheetOpen, setSheetOpen] = useState(false);
const { holds, beginHold, cancelHold } = useHoldOnComplete(); const { holds, beginHold, cancelHold } = useHoldOnComplete();
const refresh = useCallback(() => { const refresh = useCallback(() => {
@ -203,7 +207,7 @@ export function ScheduledPanel() {
<> <>
<aside <aside
className={cn( className={cn(
"hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 sm:flex", "hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex",
collapsed ? "w-16" : "w-80" collapsed ? "w-16" : "w-80"
)} )}
> >
@ -228,6 +232,77 @@ export function ScheduledPanel() {
)} )}
</aside> </aside>
{/* Mobile (< md) entry point: a dock bar that sits at the bottom of
the app column (in-flow, so nothing overlaps it) carrying the
same overdue/today urgency signals as the desktop collapsed
rail. It opens the full panel as a bottom sheet below rather
than dedicating permanent screen width to it. */}
<nav
aria-label="Scheduled to-dos"
className="shrink-0 border-t bg-sidebar text-sidebar-foreground md:hidden"
>
<button
type="button"
onClick={() => setSheetOpen(true)}
className="flex h-14 w-full items-center justify-center gap-2 transition-colors active:bg-accent/60"
>
<CalendarClock className="size-5" />
<span className="text-sm font-semibold">Scheduled</span>
{overdueCount > 0 && (
<span
className="relative flex size-5 items-center justify-center"
aria-label={`${overdueCount} overdue`}
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
{overdueCount > 9 ? "9+" : overdueCount}
</span>
</span>
)}
{todayCount > 0 && (
<span
className="flex size-5 animate-pulse items-center justify-center rounded-full bg-white text-[10px] font-bold text-black shadow-sm ring-1 ring-border"
aria-label={`${todayCount} due today`}
>
{todayCount > 9 ? "9+" : todayCount}
</span>
)}
</button>
{/* Room for the iPhone home indicator so the bar never tucks under it. */}
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
</nav>
<DialogPrimitive.Root open={sheetOpen} onOpenChange={setSheetOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-40 bg-black/60 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 md:hidden"
/>
<DialogPrimitive.Popup
className={cn(
"fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl bg-popover text-popover-foreground shadow-2xl duration-200 md:hidden",
"data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-bottom",
"data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-bottom"
)}
>
<DialogPrimitive.Title className="sr-only">Scheduled to-dos</DialogPrimitive.Title>
{/* Grabber -- visual affordance that this is a sheet, not the whole screen. */}
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-foreground/20" aria-hidden />
<div className="flex max-h-[85dvh] min-h-0 flex-col">
<ExpandedPanel
board={displayBoard}
onToggleOccurrence={handleToggle}
onEditOccurrence={handleEdit}
onAdd={handleAdd}
onCollapse={() => setSheetOpen(false)}
notificationsSupported={notifications.supported}
notificationsEnabled={notifications.enabled}
onToggleNotifications={handleToggleNotifications}
/>
</div>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<ScheduledTodoDialog <ScheduledTodoDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
@ -414,7 +489,7 @@ function ExpandedPanel({
<Separator /> <Separator />
<div className="flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
{!board ? ( {!board ? (
<p className="p-2 text-center text-sm text-muted-foreground">Loading</p> <p className="p-2 text-center text-sm text-muted-foreground">Loading</p>
) : ( ) : (

View File

@ -100,7 +100,18 @@ export function OptionRow({
); );
} }
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) { export function ThemeToggle({
collapsed,
menuSide = "right",
menuAlign = "start",
}: {
collapsed?: boolean;
// Which side of the trigger the picker menu opens on: "right" for the
// sidebar, "bottom" when the trigger sits in the mobile top bar (where a
// right-opening menu would run off the screen).
menuSide?: "right" | "bottom";
menuAlign?: "start" | "end";
}) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
// The picker shows all the real themes in Light/Dark sections; "system" is // The picker shows all the real themes in Light/Dark sections; "system" is
// handled by a dedicated row since it resolves to one of them per device // handled by a dedicated row since it resolves to one of them per device
@ -124,7 +135,7 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
</Button> </Button>
} }
/> />
<DropdownMenuContent align="start" side="right" className="w-52"> <DropdownMenuContent align={menuAlign} side={menuSide} className="w-52">
<DropdownMenuLabel>Light</DropdownMenuLabel> <DropdownMenuLabel>Light</DropdownMenuLabel>
{LIGHT_OPTIONS.map((option) => ( {LIGHT_OPTIONS.map((option) => (
<OptionRow <OptionRow

View File

@ -53,7 +53,12 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm",
// Tall content (edit forms, the AI summary, markdown editors) must
// stay usable on a phone: cap the height and let the dialog scroll
// instead of clipping off-screen.
"max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className
)} )}
{...props} {...props}

BIN
shots/m01-home-board.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
shots/m02-home-drawer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
shots/m03-home-lane2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
shots/m05-projects.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
shots/m06-chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
shots/m07-tablet-768.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
shots/m08-desktop-1440.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB