Skip to content

Commit c6ec186

Browse files
ralyodioclaude
andcommitted
fix(projects): preview flakiness, logo on tabs, stable navbar
Three fixes to the project pages that surfaced from real usage: - preview generation: poll window 120s → 300s (the "30-60s" promise was usually a lie under load), latest endpoint now filters to status=ready so we don't surface an in-flight row, and Generate stays on the dashboard + refreshes so the preview link reliably appears in the amber section instead of redirecting to a half-empty article page - project header logo: scraped favicon/og-image now renders on every project tab via a new ProjectHeaderLogo (matches the dashboard tile) - tabs nav stability: ProjectLayoutClient was missing the status / schedule / run-now action bar that ProjectShell renders, so switching e.g. scans → autoblog shifted the tabs up by a row. Layout now mirrors the shell so the nav stays put Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 952a5ab commit c6ec186

9 files changed

Lines changed: 109 additions & 20 deletions

File tree

‎app/(app)/projects/[id]/autoblog/actions.tsx‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,19 @@ async function call(path: string): Promise<{ ok: boolean; error?: string }> {
1414
}
1515
}
1616

17-
// Wait up to ~90s for a new article to land in lx_article (created_at >
18-
// `since`). Returns the article id or null on timeout. The dashboard
19-
// generate button uses this to auto-redirect once the worker is done,
20-
// so users don't sit on a "queued" message wondering what to do.
21-
// Scoped to the current project's lx_site via projectId so users with
22-
// multiple projects don't poll the wrong site when the picker cookie
23-
// has drifted.
17+
// Poll lx_article for a new row in status='ready' created after `since`.
18+
// Returns the article id or null on timeout. Scoped to the current
19+
// project's lx_site via projectId so users with multiple projects don't
20+
// poll the wrong site when the picker cookie has drifted.
21+
//
22+
// Window is generous (5 min): the worker's "30-60s" ballpark is a best
23+
// case — LLM + sitemap + embeddings can push it past 2 min, and a short
24+
// window meant the dashboard would give up before the article landed.
2425
async function waitForNewArticle(
2526
projectId: string,
2627
since: Date,
2728
signal: AbortSignal,
28-
maxMs = 120_000,
29+
maxMs = 300_000,
2930
): Promise<string | null> {
3031
const start = Date.now();
3132
while (Date.now() - start < maxMs) {
@@ -78,7 +79,7 @@ export function DashboardActions({
7879

7980
async function generateArticleNow() {
8081
setBusy("article");
81-
setNotice("Generating… this takes ~30–60s.");
82+
setNotice("Generating… this can take 1–3 minutes.");
8283
setError(null);
8384
const since = new Date();
8485
const r = await call(
@@ -98,10 +99,16 @@ export function DashboardActions({
9899
);
99100
setBusy(null);
100101
if (articleId) {
101-
router.push(`/projects/${projectId}/autoblog/articles/${articleId}`);
102+
// Stay on the dashboard so the new preview link appears at the
103+
// top with the rest of the queue. Redirecting away was confusing
104+
// when users wanted to compare multiple previews; the link in the
105+
// amber "Previews waiting on Publish" section goes to the same
106+
// place when they're ready to review.
107+
setNotice("Preview ready — see the section at the top.");
108+
router.refresh();
102109
} else {
103110
setNotice(
104-
"Still generating. Check the 'Previews waiting on Publish' section in a moment.",
111+
"Still generating. Refresh this page in a minute to see the preview at the top.",
105112
);
106113
router.refresh();
107114
}

‎app/(app)/projects/[id]/layout-client.tsx‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,35 @@
33
import Link from "next/link";
44
import { usePathname } from "next/navigation";
55
import { ProjectTabsNav } from "@/components/project-tabs-nav";
6+
import { ProjectStatusControls } from "@/components/project-status-controls";
7+
import { ScheduleToggle } from "@/components/schedule-toggle";
8+
import { RunNowButton } from "@/components/run-now-button";
9+
import { ProjectHeaderLogo } from "@/components/project-header-logo";
10+
import type { ProjectStatus } from "@/app/actions/projects";
11+
import type { Engine } from "@/lib/credits";
612

7-
// The light header (breadcrumb + name) only renders on the
8-
// autoblog/social tabs. The overview/performance/scans tabs still
9-
// render their own ProjectShell-based header until those pages are
10-
// refactored to lean on the layout.
13+
// On the autoblog/social tabs the layout owns the header chrome.
14+
// Mirrors ProjectShell exactly (logo + name + action bar + tabs) so the
15+
// tabs nav stays at the same Y across all project tabs — otherwise it
16+
// jumps when switching e.g. /scans → /autoblog because the action bar
17+
// height changes.
1118
export function ProjectLayoutClient({
1219
projectId,
1320
name,
1421
url,
22+
logoUrl,
23+
schedule,
24+
status,
25+
engines,
1526
children,
1627
}: {
1728
projectId: string;
1829
name: string;
1930
url: string;
31+
logoUrl: string | null;
32+
schedule: "off" | "daily" | "weekly" | "monthly";
33+
status: ProjectStatus;
34+
engines: Engine[];
2035
children: React.ReactNode;
2136
}) {
2237
const pathname = usePathname() ?? "";
@@ -30,9 +45,19 @@ export function ProjectLayoutClient({
3045
<Link href="/dashboard" className="text-sm text-[var(--color-muted)]">
3146
← Dashboard
3247
</Link>
33-
<h1 className="mt-3 text-3xl font-bold">{name}</h1>
48+
<div className="mt-3 flex items-center gap-3">
49+
<ProjectHeaderLogo url={logoUrl} name={name} />
50+
<h1 className="text-3xl font-bold">{name}</h1>
51+
</div>
3452
<p className="mt-1 break-all text-[var(--color-muted)]">{url}</p>
3553
</div>
54+
55+
<div className="flex flex-wrap items-center gap-3">
56+
<ProjectStatusControls projectId={projectId} status={status} />
57+
<ScheduleToggle projectId={projectId} current={schedule} />
58+
<RunNowButton projectId={projectId} url={url} engines={engines} />
59+
</div>
60+
3661
<ProjectTabsNav projectId={projectId} />
3762
</>
3863
)}

‎app/(app)/projects/[id]/layout.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default async function ProjectLayout({
1313
const supabase = await createClient();
1414
const { data: project } = await supabase
1515
.from("projects")
16-
.select("id, name, url")
16+
.select("id, name, url, logo_url, schedule, status, engines")
1717
.eq("id", id)
1818
.maybeSingle();
1919
if (!project) notFound();
@@ -23,6 +23,10 @@ export default async function ProjectLayout({
2323
projectId={project.id}
2424
name={project.name}
2525
url={project.url}
26+
logoUrl={project.logo_url ?? null}
27+
schedule={project.schedule}
28+
status={project.status ?? "active"}
29+
engines={project.engines ?? ["rule"]}
2630
>
2731
{children}
2832
</ProjectLayoutClient>

‎app/(app)/projects/[id]/page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default async function ProjectOverviewPage({
6464
schedule: project.schedule,
6565
status: (project.status ?? "active") as ProjectStatus,
6666
engines: (project.engines ?? ["rule"]) as Engine[],
67+
logo_url: (project as { logo_url?: string | null }).logo_url ?? null,
6768
}}
6869
currentTab="overview"
6970
>

‎app/(app)/projects/[id]/performance/page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export default async function ProjectPerformancePage({
133133
schedule: project.schedule,
134134
status: (project.status ?? "active") as ProjectStatus,
135135
engines: (project.engines ?? ["rule"]) as Engine[],
136+
logo_url: (project as { logo_url?: string | null }).logo_url ?? null,
136137
}}
137138
currentTab="performance"
138139
>

‎app/(app)/projects/[id]/scans/page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default async function ProjectScansPage({
9595
schedule: project.schedule,
9696
status: (project.status ?? "active") as ProjectStatus,
9797
engines: (project.engines ?? ["rule"]) as Engine[],
98+
logo_url: (project as { logo_url?: string | null }).logo_url ?? null,
9899
}}
99100
currentTab="scans"
100101
>

‎app/api/lx/articles/latest/route.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,23 @@ export async function GET(req: NextRequest) {
4545
return NextResponse.json({ ok: false, error: "bad since" }, { status: 400 });
4646
}
4747

48-
const { data, error } = await supabase
48+
// Default to status='ready' (i.e. an article the user can actually
49+
// preview/publish). The dashboard polls for this after a manual
50+
// Generate; without the filter we'd return an in-flight 'generating'
51+
// row and the redirect would land on an empty article page. Callers
52+
// that want any new row can pass ?status=any.
53+
const statusFilter = req.nextUrl.searchParams.get("status") ?? "ready";
54+
55+
let query = supabase
4956
.from("lx_article")
5057
.select("id, status, title, created_at")
5158
.eq("site_id", lxSiteId)
52-
.gt("created_at", since.toISOString())
59+
.gt("created_at", since.toISOString());
60+
if (statusFilter !== "any") {
61+
query = query.eq("status", statusFilter);
62+
}
63+
64+
const { data, error } = await query
5365
.order("created_at", { ascending: false })
5466
.limit(1)
5567
.maybeSingle();

‎components/project-header-logo.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Square logo for the project header — scraped favicon/og-image with a
2+
// one-letter fallback. Mirrors the dashboard tile's ProjectLogo, sized
3+
// up for the page header.
4+
export function ProjectHeaderLogo({
5+
url,
6+
name,
7+
}: {
8+
url: string | null;
9+
name: string;
10+
}) {
11+
const letter = (name || "?").trim().charAt(0).toUpperCase();
12+
if (!url) {
13+
return (
14+
<div
15+
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-card)] text-sm font-semibold text-[var(--color-muted)]"
16+
aria-hidden
17+
>
18+
{letter}
19+
</div>
20+
);
21+
}
22+
return (
23+
// eslint-disable-next-line @next/next/no-img-element
24+
<img
25+
src={url}
26+
alt=""
27+
width={40}
28+
height={40}
29+
loading="lazy"
30+
className="h-10 w-10 shrink-0 rounded-md border border-[var(--color-border)] bg-white object-contain p-1"
31+
/>
32+
);
33+
}

‎components/project-shell.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from "next/link";
22
import { ScheduleToggle } from "@/components/schedule-toggle";
33
import { ProjectStatusControls } from "@/components/project-status-controls";
44
import { RunNowButton } from "@/components/run-now-button";
5+
import { ProjectHeaderLogo } from "@/components/project-header-logo";
56
import type { ProjectStatus } from "@/app/actions/projects";
67
import type { Engine } from "@/lib/credits";
78

@@ -19,6 +20,7 @@ type ProjectLite = {
1920
schedule: "off" | "daily" | "weekly" | "monthly";
2021
status: ProjectStatus | null;
2122
engines: Engine[] | null;
23+
logo_url?: string | null;
2224
};
2325

2426
const TABS: { id: ProjectShellTab; label: string; href: (id: string) => string }[] = [
@@ -54,7 +56,10 @@ export function ProjectShell({
5456
<Link href="/dashboard" className="text-sm text-[var(--color-muted)]">
5557
← Dashboard
5658
</Link>
57-
<h1 className="mt-3 text-3xl font-bold">{project.name}</h1>
59+
<div className="mt-3 flex items-center gap-3">
60+
<ProjectHeaderLogo url={project.logo_url ?? null} name={project.name} />
61+
<h1 className="text-3xl font-bold">{project.name}</h1>
62+
</div>
5863
<p className="mt-1 break-all text-[var(--color-muted)]">{project.url}</p>
5964
</div>
6065

0 commit comments

Comments
 (0)