From 86cbba3cdb984a3e3ac459a4e47522dc2800907f Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:19:16 -0400 Subject: [PATCH] Project switcher: fix viewport overflow + searchable combobox (#427) --- package.json | 1 + scripts/seed-projects.ts | 194 ++++++++++ .../features/projects/ProjectSwitcher.tsx | 332 +++++++++++++++--- 3 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 scripts/seed-projects.ts diff --git a/package.json b/package.json index fd0a67f..473b335 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "billing:usage": "tsx scripts/dataforseo-account-usage.ts", "cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", + "seed:projects": "tsx scripts/seed-projects.ts", "ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware" }, "cloudflare": { diff --git a/scripts/seed-projects.ts b/scripts/seed-projects.ts new file mode 100644 index 0000000..ff2ae59 --- /dev/null +++ b/scripts/seed-projects.ts @@ -0,0 +1,194 @@ +/** + * Seed the local D1 database with demo projects so the project switcher and + * /projects page can be exercised with a long list (agency-style account). + * Fully offline — no DataForSEO key or network needed. + * + * Usage: + * pnpm db:migrate:local # once — creates the local D1 + * pnpm seed:projects # seed 18 demo projects + * pnpm seed:projects --count=30 + * pnpm seed:projects --clean # remove previously seeded demo projects + * + * Then view them: + * env AUTH_MODE=local_noauth pnpm dev + * + * Bootstraps the same local_noauth user/org that `AUTH_MODE=local_noauth` + * resolves, so the projects are immediately visible without signing up. + * Re-running skips projects that already exist (matched by name), so it is + * safe to run repeatedly. + */ + +import process from "node:process"; +import { getPlatformProxy } from "wrangler"; +import { drizzle } from "drizzle-orm/d1"; +import { and, eq, inArray, isNull, like } from "drizzle-orm"; +// Import the sqlite schema files directly: the "../src/db/schema" barrel pulls +// in provider.ts, whose `cloudflare:workers` import Node's loader can't +// resolve outside the Workers runtime. +import * as appSchema from "../src/db/app.schema"; +import { organization, user } from "../src/db/better-auth-schema"; +import { parseArgs } from "./cli-utils"; + +const schema = { ...appSchema, organization, user }; + +const LOCAL_ADMIN_USER_ID = "local-admin"; +const LOCAL_ADMIN_EMAIL = "admin@localhost"; +const LOCAL_ORG_ID = `delegated-${LOCAL_ADMIN_USER_ID}`; + +// Suffix marks rows as seeded demo data so --clean can find them without +// touching projects the user created by hand. +const DEMO_DOMAIN_SUFFIX = ".demo-seed.test"; + +const DEMO_PROJECTS = [ + "Flytedesk", + "Steadfast Paving", + "Greenhow", + "Oliver Realty", + "Intl. Designers", + "Zabella", + "Collis Roofing", + "We Lend", + "EarlyBird Electricians", + "Paul Bunyan Plumbing", + "Blue Ox HVAC", + "Yearli", + "Spakinect", + "MVP Logistics", + "Northside Dental", + "Harbor & Vine", + "Summit Physio", + "Lakeview Storage", + "Copperline Coffee", + "Trailhead Outfitters", + "Bellamy Law", + "Quartz Analytics", + "Fernwood Nursery", + "Ironclad Fencing", + "Beacon Tutoring", + "Riverbend Vet", + "Solstice Yoga", + "Granite Countertops Co", + "Pinewood Cabinets", + "Atlas Moving", +] as const; + +type SeedDb = ReturnType>; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const count = clampInt(args.count, 18, 1, DEMO_PROJECTS.length); + + console.log("Setting up local D1 connection..."); + const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>(); + const db = drizzle(env.DB, { schema }); + + try { + if (args.clean === "true") { + await clean(db); + return; + } + + await bootstrapLocalIdentity(db); + + const existing = await db.query.projects.findMany({ + where: and( + eq(schema.projects.organizationId, LOCAL_ORG_ID), + isNull(schema.projects.archivedAt), + ), + columns: { name: true }, + }); + const existingNames = new Set(existing.map((project) => project.name)); + + let created = 0; + for (const name of DEMO_PROJECTS.slice(0, count)) { + if (existingNames.has(name)) continue; + await db.insert(schema.projects).values({ + id: crypto.randomUUID(), + organizationId: LOCAL_ORG_ID, + name, + domain: toDomain(name), + }); + created += 1; + } + + console.log( + `Done. Created ${created} project(s); ${count - created} already existed.`, + ); + console.log("View them with: env AUTH_MODE=local_noauth pnpm dev"); + } finally { + await dispose(); + } +} + +async function clean(db: SeedDb) { + const seeded = await db.query.projects.findMany({ + where: and( + eq(schema.projects.organizationId, LOCAL_ORG_ID), + like(schema.projects.domain, `%${DEMO_DOMAIN_SUFFIX}`), + ), + columns: { id: true }, + }); + if (seeded.length === 0) { + console.log("No seeded demo projects found."); + return; + } + await db.delete(schema.projects).where( + inArray( + schema.projects.id, + seeded.map((project) => project.id), + ), + ); + console.log(`Removed ${seeded.length} seeded demo project(s).`); +} + +async function bootstrapLocalIdentity(db: SeedDb) { + await db + .insert(schema.user) + .values({ + id: LOCAL_ADMIN_USER_ID, + name: "admin", + email: LOCAL_ADMIN_EMAIL, + emailVerified: true, + }) + .onConflictDoNothing({ target: schema.user.id }); + + await db + .insert(schema.organization) + .values({ + id: LOCAL_ORG_ID, + name: "admin workspace", + slug: `delegated-admin-${toHex(LOCAL_ADMIN_USER_ID)}`, + createdAt: new Date(), + }) + .onConflictDoNothing({ target: schema.organization.id }); +} + +function toDomain(name: string) { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "") + .slice(0, 40); + return `${slug}${DEMO_DOMAIN_SUFFIX}`; +} + +function toHex(value: string) { + return Array.from(new TextEncoder().encode(value)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function clampInt( + raw: string | undefined, + fallback: number, + min: number, + max: number, +) { + const parsed = Number.parseInt(raw ?? "", 10); + if (Number.isNaN(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/client/features/projects/ProjectSwitcher.tsx b/src/client/features/projects/ProjectSwitcher.tsx index 6350632..b8545de 100644 --- a/src/client/features/projects/ProjectSwitcher.tsx +++ b/src/client/features/projects/ProjectSwitcher.tsx @@ -1,11 +1,15 @@ +import * as React from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronsUpDown, FolderCog } from "lucide-react"; +import { Check, ChevronsUpDown, FolderCog, Plus, Search } from "lucide-react"; import { getProjects } from "@/serverFunctions/projects"; import { setLastProjectId } from "@/client/lib/active-project"; -import { closeDropdown } from "@/client/lib/dropdown"; +import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal"; import type { ProjectSummary } from "./types"; +// Below this many projects the plain list is faster to scan than a search box. +const SEARCH_THRESHOLD = 8; + export function ProjectSwitcher({ activeProjectId, onCloseDrawer, @@ -16,6 +20,19 @@ export function ProjectSwitcher({ onCloseDrawer?: () => void; }) { const navigate = useNavigate(); + const [creating, setCreating] = React.useState(false); + // Controlled open state rather than daisyUI's CSS focus-within dropdown: + // focus-within can't guarantee the search input ends up focused on open + // (Safari never focuses buttons on click, and moving focus into the panel + // is exactly what a combobox needs). + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [highlightIndex, setHighlightIndex] = React.useState(0); + const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const listRef = React.useRef(null); + const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: () => getProjects(), @@ -24,8 +41,38 @@ export function ProjectSwitcher({ const activeProject = projects.find((project) => project.id === activeProjectId) ?? null; + const showSearch = projects.length >= SEARCH_THRESHOLD; + const normalizedQuery = query.trim().toLowerCase(); + const filteredProjects = normalizedQuery + ? projects.filter( + (project) => + project.name.toLowerCase().includes(normalizedQuery) || + project.domain?.toLowerCase().includes(normalizedQuery), + ) + : projects; + + const openPanel = () => { + setQuery(""); + setHighlightIndex(0); + setOpen(true); + }; + + const closePanel = () => { + setOpen(false); + setQuery(""); + }; + + // Opening the panel puts the caret straight in the search box, so + // click → type → Enter selects a project with no extra step. Touch devices + // are skipped: autofocus would pop the keyboard over the list. + React.useEffect(() => { + if (!open || !showSearch) return; + if (window.matchMedia("(pointer: coarse)").matches) return; + searchInputRef.current?.focus(); + }, [open, showSearch]); + const handleSelect = (project: ProjectSummary) => { - closeDropdown(); + closePanel(); onCloseDrawer?.(); if (project.id === activeProjectId) return; setLastProjectId(project.id); @@ -35,12 +82,122 @@ export function ProjectSwitcher({ }); }; + const moveHighlight = (delta: number) => { + setHighlightIndex((index) => { + const next = index + delta; + if (next < 0) return 0; + if (next > filteredProjects.length - 1) + return filteredProjects.length - 1; + return next; + }); + }; + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveHighlight(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + moveHighlight(-1); + } else if (event.key === "Enter") { + event.preventDefault(); + const project = filteredProjects[highlightIndex] ?? filteredProjects[0]; + if (project) handleSelect(project); + } + // Escape is handled once at the root so it also works from the project + // list and footer buttons. + }; + + // Type-ahead fallback on the trigger: if the user types while focus is + // still on the trigger (touch skips autofocus; focus can also stay here + // between open and the focus effect), open the panel and route the + // keystroke into the search box instead of dropping it. Deliberately not + // on the wrapper — that would also swallow keystrokes bubbling from the + // menu items and the create-project modal rendered inside it. + const handleTriggerKeyDown = (event: React.KeyboardEvent) => { + if (!showSearch) return; + const isCharacter = + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey; + if (isCharacter || event.key === "Backspace") { + event.preventDefault(); + if (!open) setOpen(true); + setQuery((current) => + isCharacter ? current + event.key : current.slice(0, -1), + ); + setHighlightIndex(0); + searchInputRef.current?.focus(); + } else if (!open && event.key === "ArrowDown") { + event.preventDefault(); + openPanel(); + } else if (open && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) { + handleSearchKeyDown(event); + } + }; + + // Escape closes the panel from anywhere inside the switcher — trigger, + // search box, project list, or footer. When the create-project modal is up + // the panel is already closed, so this never swallows the modal's own + // Escape handling. + const handleRootKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Escape" || !open) return; + event.preventDefault(); + closePanel(); + triggerRef.current?.focus(); + }; + + // Close on clicks outside the switcher. Focus loss alone isn't used for + // this (clicking a non-focusable spot inside the panel blurs to ), + // so pointerdown containment is the single source of truth for "outside". + React.useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node) || !rootRef.current?.contains(target)) { + closePanel(); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [open]); + + // Also close when keyboard focus tabs out of the switcher entirely. + const handleRootBlur = (event: React.FocusEvent) => { + if (!open) return; + const next = event.relatedTarget; + if (next instanceof Node && !rootRef.current?.contains(next)) closePanel(); + }; + + // Keep the keyboard highlight visible while arrowing through a scrolled + // list. + React.useEffect(() => { + const highlighted = listRef.current?.querySelector( + '[data-highlighted="true"]', + ); + highlighted?.scrollIntoView({ block: "nearest" }); + }, [highlightIndex]); + return ( -
+
-
    - {projects.map((project) => { - const isActive = project.id === activeProjectId; - return ( -
  • + {open ? ( +
    + {showSearch ? ( +
    + +
    + ) : null} + + {projects.length > 0 ? ( + // Long project lists scroll inside the dropdown; without the cap the + // menu grows past the viewport and the footer becomes unreachable. + // flex-nowrap because daisyUI menus wrap into columns by default. +
      + {filteredProjects.map((project, index) => { + const isActive = project.id === activeProjectId; + const isHighlighted = showSearch && index === highlightIndex; + return ( +
    • + +
    • + ); + })} + {filteredProjects.length === 0 ? ( +
    • + + No projects match “{query.trim()}” + +
    • + ) : null} +
    + ) : null} + +
      0 ? "border-t border-base-300" : "" + }`} + > +
    • - ); - })} +
    • + { + closePanel(); + onCloseDrawer?.(); + }} + > + + Manage projects + +
    • +
    +
    + ) : null} - {projects.length > 0 ? ( -
  • - { - closeDropdown(); - onCloseDrawer?.(); - }} - > - - Manage projects - -
  • -
+ {creating ? ( + { + setCreating(false); + onCloseDrawer?.(); + }} + /> + ) : null}
); }