Merge pull request 'Add responsive mobile layout for the app shell and board' (#6) from Mobile into main
Reviewed-on: #6
|
|
@ -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); });
|
||||||
|
|
@ -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,15 +41,20 @@ 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
|
||||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
ScheduledPanel) take the full column with no horizontal
|
||||||
<main className="flex-1 overflow-auto">{children}</main>
|
scrolling; h-dvh tracks the mobile browser's dynamic
|
||||||
<ScheduledPanel />
|
toolbar instead of the stale 100vh. */}
|
||||||
</div>
|
<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} />
|
||||||
|
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
|
||||||
|
<ScheduledPanel />
|
||||||
|
</div>
|
||||||
</HoldOnCompleteProvider>
|
</HoldOnCompleteProvider>
|
||||||
</BoardViewProvider>
|
</BoardViewProvider>
|
||||||
</ProjectsProvider>
|
</ProjectsProvider>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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,19 +246,37 @@ 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">
|
<>
|
||||||
<SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}>
|
{/* Mobile only: the category rail. On desktop the lanes are
|
||||||
{categories.map((category) => (
|
already labeled and side-by-side, so this stays hidden. */}
|
||||||
<CategoryLane key={category.id} category={category} />
|
<div className="md:hidden">
|
||||||
))}
|
<CategoryChips
|
||||||
</SortableContext>
|
categories={categories}
|
||||||
<AddCategoryLane />
|
activeCategoryId={activeCategoryId ?? categories[0]?.id ?? null}
|
||||||
</div>
|
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}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryLane key={category.id} category={category} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<AddCategoryLane />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,12 +92,64 @@ 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>
|
||||||
|
</>
|
||||||
<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">
|
}
|
||||||
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
|
|
||||||
</Button>
|
/**
|
||||||
</div>
|
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
||||||
</aside>
|
* 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")}>
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
|
||||||
|
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<MobileNavDrawer userEmail={userEmail} role={role} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 71 KiB |