diff --git a/app/(app)/projects/[id]/stats/careers/careers-install.tsx b/app/(app)/projects/[id]/stats/careers/careers-install.tsx index a49d0e7..29fe553 100644 --- a/app/(app)/projects/[id]/stats/careers/careers-install.tsx +++ b/app/(app)/projects/[id]/stats/careers/careers-install.tsx @@ -3,9 +3,14 @@ // "Add a real /careers page to my repo" — the crawlable counterpart to the // drop-in widget. // -// Two steps on purpose. Detection is read-only and tells the user what we -// found before anything is written; only the second button opens a PR. People -// are reasonably wary of a button that commits to their repo sight unseen. +// Two steps on purpose. Scanning is read-only and shows what we found before +// anything is written; only the second button opens a PR. People are reasonably +// wary of a button that commits to their repo sight unseen. +// +// The scan lists every place the route could go, ranked, the way the stats +// tracker installer does. Asking someone to type their own monorepo path and +// answering a miss with "not supported" was the wrong shape: in a monorepo the +// site is rarely at the root, and a wrong guess looked like a missing feature. import { useState } from "react"; import type { RepoOption } from "@/lib/github/repo-options"; @@ -17,6 +22,11 @@ interface Detected { evidence: string; } +interface Candidate extends Detected { + score: number; + existingPath?: string; +} + interface InstallResult { status: "opened" | "noop"; prUrl?: string; @@ -45,12 +55,25 @@ export function CareersInstall({ const [rootPath, setRootPath] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [detected, setDetected] = useState(undefined); + const [candidates, setCandidates] = useState(undefined); + const [truncated, setTruncated] = useState(false); + const [chosenDir, setChosenDir] = useState(""); + const [manualDir, setManualDir] = useState(""); const [result, setResult] = useState(null); const selected = repos.find((r) => r.full_name === repo); + const chosen = candidates?.find((c) => c.dir === chosenDir); + const targetDir = chosenDir || manualDir.trim(); + + /** Any change to repo or root invalidates a scan taken against the old one. */ + function resetScan() { + setCandidates(undefined); + setChosenDir(""); + setResult(null); + setError(null); + } - async function call(mode: "detect" | "submit") { + async function call(mode: "candidates" | "submit") { if (!selected) return; const [owner, name] = selected.full_name.split("/"); setBusy(true); @@ -65,6 +88,7 @@ export function CareersInstall({ installation_id: selected.installation_id, default_branch: selected.default_branch ?? undefined, root_path: rootPath.trim() || undefined, + target_dir: mode === "submit" ? targetDir || undefined : undefined, mode, }), }); @@ -73,8 +97,16 @@ export function CareersInstall({ setError(payload?.error ?? "Something went wrong."); return; } - if (mode === "detect") setDetected(payload.data.detected); - else setResult(payload.data); + if (mode === "candidates") { + const found: Candidate[] = payload.data.candidates ?? []; + setCandidates(found); + setTruncated(Boolean(payload.data.truncated)); + // Preselect the best match; it is right in the common case and the + // list is there when it isn't. + setChosenDir(found[0]?.dir ?? ""); + } else { + setResult(payload.data); + } } catch { setError("Could not reach GitHub. Try again."); } finally { @@ -119,8 +151,7 @@ export function CareersInstall({ value={repo} onChange={(e) => { setRepo(e.target.value); - setDetected(undefined); - setResult(null); + resetScan(); }} className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" > @@ -135,34 +166,102 @@ export function CareersInstall({
{ setRootPath(e.target.value); - setDetected(undefined); + resetScan(); }} - placeholder="apps/web — leave blank for the repo root" + placeholder="apps/web — leave blank to scan the whole repo" className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" />
{error &&

{error}

} - {detected === null && ( + {candidates?.length === 0 && (

- No Next.js App Router or Astro site found there. Nothing was changed — your - board still works through the tracker snippet, it just renders client-side. + No Next.js App Router or Astro site found in this repo. Nothing was changed — + your board still works through the tracker snippet, it just renders + client-side. If the site lives somewhere unusual, enter the directory holding{" "} + layout.tsx below.

)} - {detected && ( + {!!candidates?.length && ( +
+

+ {candidates.length === 1 + ? "Found one place the page can go:" + : `Found ${candidates.length} places the page can go, best match first:`} +

+
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ {truncated && ( +

+ This repo is large enough that GitHub truncated the file listing, so the + scan may have missed a location. Enter it below if it isn't here. +

+ )} +
+ )} + + {candidates !== undefined && ( +
+ + { + setManualDir(e.target.value); + setChosenDir(""); + setResult(null); + }} + placeholder="apps/web/src/app — the directory containing layout.tsx" + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm" + /> +
+ )} + + {chosen && (

- Found {FRAMEWORK_LABEL[detected.framework]} from{" "} - {detected.evidence}. The PR will add the - page under {detected.dir}. + The PR will add the page under {chosen.dir}{" "} + as {FRAMEWORK_LABEL[chosen.framework]}.

)} @@ -185,16 +284,16 @@ export function CareersInstall({