Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions apps/web/src/components/ScreenshotsByPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ import {
/** How many path groups a project section previews before "view project →". */
const PREVIEW_PATHS_PER_PROJECT = 3;

const EMPTY_CTA_CMD = "uploads screenshot https://app.example/settings";

/**
* Gallery-style empty state (renderGalleriesEmptyHtml's markup, React-side):
* a title and ONE copyable command, nothing else competing for attention —
* the rail tip carries the how-grouping-works detail.
*/
function EmptyShotsCta({ title }: { title: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(EMPTY_CTA_CMD);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
// clipboard blocked — leave the label
}
};
return (
<div className="ws-empty-state ws-empty-state--cta">
<p className="ws-empty-state__title">{title}</p>
<div className="command ws-empty__command">
<code>{EMPTY_CTA_CMD}</code>
<button type="button" aria-live="polite" onClick={() => void copy()}>
{copied ? "copied ✓" : "copy"}
</button>
</div>
</div>
);
}

interface ScreenshotsByPathProps {
apiOrigin: string;
workspace: string;
Expand Down Expand Up @@ -461,14 +492,7 @@ export function ScreenshotsByPath({ apiOrigin, workspace }: ScreenshotsByPathPro
</button>
<h2 className="wsp-drill__heading">{view.project}</h2>
{isEmpty ? (
<div className="ws-empty-state">
<p className="ws-empty-state__title">No screenshots for this project</p>
<p className="ws-empty-state__body">
<code>uploads screenshot</code> records the page it captured automatically, and any
upload can pass <code>--meta path=/settings</code> to group here. See{" "}
<a href="/docs">the docs</a> for details.
</p>
</div>
<EmptyShotsCta title="No screenshots for this project" />
) : (
<>
{projectGroups.map((group) => (
Expand All @@ -494,14 +518,7 @@ export function ScreenshotsByPath({ apiOrigin, workspace }: ScreenshotsByPathPro
return (
<div className="wsp">
{sectionLabels.length === 0 ? (
<div className="ws-empty-state">
<p className="ws-empty-state__title">No screenshots with a path yet</p>
<p className="ws-empty-state__body">
<code>uploads screenshot</code> records the page it captured automatically, and any
upload can pass <code>--meta path=/settings</code> to group here. See{" "}
<a href="/docs">the docs</a> for details.
</p>
</div>
<EmptyShotsCta title="No screenshots yet" />
) : (
<>
{sectionLabels.map((label) => {
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,18 @@ const stars = await githubStarCount();
<nav class="site-header__nav" aria-label="Site">
{
/*
Files is signed-in only. AuthIndicator toggles [data-header-files]
after the session check (optimistic paint from session cache).
Screenshots is signed-in only. AuthIndicator toggles
[data-header-files] after the session check (optimistic paint from
session cache). `?to=screenshots` makes the workspaces index's
auto-open land on the active workspace's screenshots tab.
*/
}
<a class="site-header__link" data-header-files href="/account/workspaces" hidden>Files</a>
<a
class="site-header__link"
data-header-files
href="/account/workspaces?to=screenshots"
hidden>Screenshots</a
>
<a class="site-header__link" href="/docs">Docs</a>
</nav>
<a class="star-cta" href={REPO} aria-label="Star buildinternet/uploads on GitHub">
Expand Down
70 changes: 44 additions & 26 deletions apps/web/src/layouts/WorkspaceLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,20 @@ interface Props {
* gets the normal-width rail.
*/
wideRail?: boolean;
/**
* Hide the rail's quick-actions section (GitHub App CTA + invite link) on
* tabs where those calls to action are noise (e.g. screenshots).
*/
showQuickActions?: boolean;
}

const { workspace, showUsage = false, tip, wideRail = false } = Astro.props;
const {
workspace,
showUsage = false,
tip,
wideRail = false,
showQuickActions = true,
} = Astro.props;
const hasPreview = Astro.slots.has("preview");

const invitePath = `/account/workspaces/${encodeURIComponent(workspace)}/people`;
Expand Down Expand Up @@ -103,31 +114,38 @@ const invitePath = `/account/workspaces/${encodeURIComponent(workspace)}/people`
)
}

<section class="ws-rail__section">
<div class="ws-rail__head">
<span class="ws-rail__label">quick actions</span>
<span class="ws-rail__rule"></span>
</div>
<div class="ws-rail__actions">
{
/* Server-rendered visible, then hidden by `initWorkspaceRail` when
`/v1/workspaces/:name/github/status` reports the App installed
(issue #492). Shown on every other outcome — a workspace that has
it already sees one extra muted link; one that doesn't would
otherwise never learn the App exists. */
}
<a
class="ws-rail__cta"
data-rail-github-cta
href={GITHUB_APP_INSTALL_URL}
target="_blank"
rel="noopener noreferrer"
>
install github app<span class="ws-rail__cta-go" aria-hidden="true">→</span>
</a>
<a class="ws-rail__invite" href={invitePath}>invite teammate</a>
</div>
</section>
{
showQuickActions && (
<section class="ws-rail__section">
<div class="ws-rail__head">
<span class="ws-rail__label">quick actions</span>
<span class="ws-rail__rule" />
</div>
<div class="ws-rail__actions">
{/* Server-rendered visible, then hidden by `initWorkspaceRail` when
`/v1/workspaces/:name/github/status` reports the App installed
(issue #492). Shown on every other outcome — a workspace that has
it already sees one extra muted link; one that doesn't would
otherwise never learn the App exists. */}
<a
class="ws-rail__cta"
data-rail-github-cta
href={GITHUB_APP_INSTALL_URL}
target="_blank"
rel="noopener noreferrer"
>
install github app
<span class="ws-rail__cta-go" aria-hidden="true">
</span>
</a>
<a class="ws-rail__invite" href={invitePath}>
invite teammate
</a>
</div>
</section>
)
}
</div>
</Fragment>
</AccountLayout>
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/lib/workspace-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,21 @@ export function renderGalleriesPlaceholderHtml(rows = 3): string {
</div>`;
}

/**
* Screenshots-page placeholder — byte-for-byte the markup of
* `OverviewLoadingSkeleton` in components/ScreenshotsByPath.tsx, so the
* server-rendered gap before the React island's dynamic import resolves
* shows the same skeleton the component then renders: no dead space, no
* layout jump. Change the two together.
*/
export function renderScreenshotsPlaceholderHtml(rows = 3, thumbs = 4): string {
const group = `<div class="wsp-group">
<div class="wsp-group__head">${skeletonBarHtml("140px")}</div>
<div class="wsp-strip">${`<span class="wsp-thumb wsp-thumb--skel" aria-hidden="true"></span>`.repeat(thumbs)}</div>
</div>`;
return `<div class="wsp" aria-busy="true">${group.repeat(rows)}</div>`;
}

/**
* Workspaces-index placeholder — same `ul.dev-links li` row (link + role
* slug) the real list renders, so the swap from placeholder to data doesn't
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/pages/account/workspaces.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ export const prerender = false;

const legacyWs = (Astro.url.searchParams.get("ws") ?? "").trim();
if (isBrowseWorkspace(legacyWs)) {
const dest = new URL(`/account/workspaces/${encodeURIComponent(legacyWs)}`, Astro.url);
// Same `?to=<tab>` allowlist as the client-side auto-open below.
const suffix = Astro.url.searchParams.get("to") === "screenshots" ? "/screenshots" : "";
const dest = new URL(`/account/workspaces/${encodeURIComponent(legacyWs)}${suffix}`, Astro.url);
dest.search = "";
for (const [key, value] of Astro.url.searchParams) {
if (key !== "ws") dest.searchParams.append(key, value);
if (key !== "ws" && key !== "to") dest.searchParams.append(key, value);
}
return Astro.redirect(`${dest.pathname}${dest.search}${dest.hash}`);
}
Expand Down Expand Up @@ -130,7 +132,12 @@ if (isBrowseWorkspace(legacyWs)) {
list.removeAttribute("aria-busy");
status.hidden = false;
status.textContent = "Opening…";
location.replace(`/account/workspaces/${encodeURIComponent(open)}`);
// `?to=<tab>` lands the auto-open on a workspace sub-tab (the
// header's Screenshots link uses it). Allowlisted so a crafted
// query can't steer the redirect anywhere else.
const to = new URLSearchParams(location.search).get("to");
const suffix = to === "screenshots" ? "/screenshots" : "";
location.replace(`/account/workspaces/${encodeURIComponent(open)}${suffix}`);
return;
}

Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/pages/account/workspaces/[name]/screenshots.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import WorkspaceLayout from "../../../../layouts/WorkspaceLayout.astro";
import { isBrowseWorkspace } from "../../../../lib/workspace-browse-url";
import { renderScreenshotsPlaceholderHtml } from "../../../../lib/workspace-ui";

export const prerender = false;

Expand All @@ -14,18 +15,20 @@ const workspace = isBrowseWorkspace(nameParam) ? nameParam : "";
if (!workspace) return Astro.redirect("/account/workspaces");

const tip = {
body: "capture with <code>uploads screenshot https://app.example/settings</code> — the page path is recorded automatically and groups shots here.",
body: "groups come from metadata: <code>path</code> for pages, <code>repo</code> or the URL host for projects.",
href: "/docs/attach-pull-request-images#put",
linkLabel: "how grouping works",
};
---

<WorkspaceLayout workspace={workspace} tip={tip}>
<WorkspaceLayout workspace={workspace} tip={tip} showQuickActions={false}>
{
/* React (dynamic-imported below) replaces this wholesale on mount — no
HTML-string placeholder builder exists for this page, so the gap before
that import resolves is empty; the component's own loading skeleton
renders immediately after mount. */
/* React (dynamic-imported below) replaces this wholesale on mount. The
placeholder mirrors the component's own OverviewLoadingSkeleton, so the
gap before that import resolves shows the same skeleton the component
renders next — no dead space, no jump. */
}
<div id="ws-screenshots"></div>
<div id="ws-screenshots" set:html={renderScreenshotsPlaceholderHtml()} />

<script>
import { onAstroPageLoad } from "../../../../lib/account-shell";
Expand Down
Loading