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
5 changes: 3 additions & 2 deletions apps/web/src/components/admin/LimitsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
multForUnit,
splitBytes,
} from "../../lib/admin-limits";
import { formatCount } from "../../lib/workspace-ui";
import { INPUT_NUM, SELECT_SM } from "./field-classes";
import { Muted, SectionHeading, StatusLine } from "./StatusLine";
import { useAdminResource } from "./use-admin-resource";
Expand Down Expand Up @@ -183,9 +184,9 @@ export function LimitsEditor({ api, workspace }: { api: AdminApi; workspace: str
cap={data.limits.maxStorageBytes}
/>
<UsageMeter
label={`${usage.uploads}${
label={`${formatCount(usage.uploads)}${
data.limits.maxUploadsPerPeriod !== null
? ` of ${data.limits.maxUploadsPerPeriod}`
? ` of ${formatCount(data.limits.maxUploadsPerPeriod)}`
: ""
} uploads this month`}
used={usage.uploads}
Expand Down
25 changes: 7 additions & 18 deletions apps/web/src/components/files/ShotThumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
leafName,
shotPreviewCaption,
shotTileInput,
type ShotPreviewCaption,
} from "../../lib/workspace-screenshots";

export interface ShotThumbItem {
Expand All @@ -36,20 +37,14 @@ export interface ShotThumbItem {
posterUrl?: string | null;
}

export type PreviewCaption = {
name: string;
pr?: string;
export type PreviewCaption = ShotPreviewCaption & {
/** GitHub kind, for the plain-text PR fallback's glyph. */
kind?: string;
/** `owner/repo#n` — key into the resolved titles map for live PR status. */
ref?: string;
/** ISO upload time, rendered as a relative "uploaded …" line. */
uploadedAt?: string;
};

export type PreviewHandlers = {
onPreviewEnter: (
el: HTMLElement,
src: string,
caption: PreviewCaption,
opts?: { immediate?: boolean },
) => void;
Expand Down Expand Up @@ -79,12 +74,7 @@ export function ShotThumb({
/** Known destination; when null the tile falls back to a button + `onOpen`. */
href: string | null;
onOpen: () => void;
onPreviewEnter?: (
el: HTMLElement,
src: string,
caption: PreviewCaption,
opts?: { immediate?: boolean },
) => void;
onPreviewEnter?: PreviewHandlers["onPreviewEnter"];
onPreviewLeave?: () => void;
}) {
const name = leafName(item.key);
Expand All @@ -99,12 +89,11 @@ export function ShotThumb({
const base = shotPreviewCaption(item);
const caption: PreviewCaption = base.pr ? { ...base, kind: ghKindValue } : base;

const previewSrc = imageSrc ? thumbUrl(imageSrc, 1120) : null;
const shared = {
className: "wsp-tile",
"aria-label": `Open ${name}${stateSuffix}${caption.pr ? ` — ${caption.pr}` : ""}${paired ? " — has before/after pair" : ""}`,
onMouseEnter: (event: { currentTarget: HTMLElement }) => {
if (previewSrc) onPreviewEnter?.(event.currentTarget, previewSrc, caption);
onPreviewEnter?.(event.currentTarget, caption);
},
onMouseLeave: () => onPreviewLeave?.(),
// Keyboard-focus parity for the hover preview: `:focus-visible` gates
Expand All @@ -113,8 +102,8 @@ export function ShotThumb({
// away from. Escape (handled in useShotPreview) dismisses it either way,
// and dismissal never moves focus, so tab order is untouched.
onFocus: (event: { currentTarget: HTMLElement }) => {
if (previewSrc && focusIsKeyboardDriven(event.currentTarget)) {
onPreviewEnter?.(event.currentTarget, previewSrc, caption, { immediate: true });
if (focusIsKeyboardDriven(event.currentTarget)) {
onPreviewEnter?.(event.currentTarget, caption, { immediate: true });
}
},
onBlur: () => onPreviewLeave?.(),
Expand Down
97 changes: 44 additions & 53 deletions apps/web/src/components/files/useShotPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Hover preview for media tiles, moved from ScreenshotsByPath. Tiles call
* Hover card for media tiles: details only (name, PR, page, state, device,
* upload time), no enlarged image — the tile is the picture. Tiles call
* `handlers.onPreviewEnter/Leave`; the caller renders `layer` once. Escape
* and any scroll dismiss it. Placement is measured in a layout effect so the
* corrected position lands before paint (shotPreviewPosition, #776).
Expand All @@ -17,19 +18,13 @@ import {
import { GhKindIcon } from "./gh-glyphs";
import type { PreviewCaption, PreviewHandlers } from "./ShotThumb";

interface PreviewState {
src: string;
/** Trigger tile's viewport box, kept around so a re-measure (image load,
* resolved title) can re-run `shotPreviewPosition` without re-reading a
* DOM node that may have scrolled or unmounted. */
interface PreviewState extends PreviewCaption {
/** Trigger tile's viewport box, kept around so a re-measure (resolved
* title) can re-run `shotPreviewPosition` without re-reading a DOM node
* that may have scrolled or unmounted. */
thumb: ShotPreviewBox;
left: number;
top: number;
name: string;
pr?: string;
kind?: string;
ref?: string;
uploadedAt?: string;
}

export function useShotPreview(
Expand Down Expand Up @@ -63,13 +58,12 @@ export function useShotPreview(
}, []);

// Flip-aware placement, measured rather than estimated: `shotPreviewPosition`
// needs the pop-over's real box (the image's intrinsic aspect ratio and the
// meta block's line count are both unknown before mount — a PR title can
// add a line, an error state adds none), so this measures the ACTUAL
// rendered element and only then decides which side/edge to place it on.
// Runs in a layout effect, which lands the corrected position before the
// browser paints — no visible jump — and re-runs whenever the full-size
// image finishes loading or a resolved title changes the box's height.
// needs the pop-over's real box (its line count is unknown before mount —
// a PR title can add a line, an error state adds none), so this measures
// the ACTUAL rendered element and only then decides which side/edge to
// place it on. Runs in a layout effect, which lands the corrected position
// before the browser paints — no visible jump — and re-runs whenever a
// resolved title changes the box's height.
const previewRef = useRef<HTMLDivElement | null>(null);
const positionPreview = () => {
const el = previewRef.current;
Expand Down Expand Up @@ -104,12 +98,7 @@ export function useShotPreview(
if (info) setTitles((prev) => ({ ...prev, [ref]: info }));
});
};
const onPreviewEnter = (
el: HTMLElement,
src: string,
caption: PreviewCaption,
opts?: { immediate?: boolean },
) => {
const onPreviewEnter: PreviewHandlers["onPreviewEnter"] = (el, caption, opts) => {
// Keyboard focus (`opts.immediate`) opens regardless of hover capability
// — `focusIsKeyboardDriven` at the call site already excludes touch taps.
if (!opts?.immediate && !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
Expand All @@ -128,17 +117,12 @@ export function useShotPreview(
bottom: rect.bottom,
};
setPreview({
src,
...caption,
thumb,
// Placeholder until the layout effect above measures the real box
// and repositions before paint.
left: thumb.right + 12,
top: thumb.top,
name: caption.name,
pr: caption.pr,
kind: caption.kind,
ref: caption.ref,
uploadedAt: caption.uploadedAt,
});
};
if (opts?.immediate) {
Expand All @@ -152,37 +136,44 @@ export function useShotPreview(
// A caption with a PR but no ref keeps its plain-text label instead of
// dropping the line (previewPrDisplay).
const previewPr = preview ? previewPrDisplay(preview, titles) : null;
const details: [string, string][] = [];
if (preview?.page) details.push(["Page", preview.page]);
if (preview?.state) details.push(["State", preview.state]);
if (preview?.device) details.push(["Device", preview.device]);
if (preview?.uploadedAt) {
details.push(["Uploaded", lastUpdatedLabel(preview.uploadedAt, new Date())]);
}
const layer = preview ? (
<div
className="wsp-preview"
ref={previewRef}
style={{ left: preview.left, top: preview.top }}
role="presentation"
>
{/* onLoad: the real image height is only known once bytes arrive —
re-clamp so a tall capture doesn't push the meta off-screen. */}
<img src={preview.src} alt="" onLoad={positionPreview} />
<div className="wsp-preview__meta">
<div className="wsp-preview__name">{preview.name}</div>
{previewPr && (
<div className="wsp-preview__pr">
{/* PrLabel draws its own state icon; the bare kind glyph is
<div className="wsp-preview__name">{preview.name}</div>
{previewPr && (
<div className="wsp-preview__pr">
{/* PrLabel draws its own state icon; the bare kind glyph is
only for the plain-text fallback. */}
{"label" in previewPr ? (
<PrLabel size="sm" {...previewPr.label} />
) : (
<>
<GhKindIcon kind={preview.kind} /> {previewPr.text}
</>
)}
</div>
)}
{preview.uploadedAt && (
<div className="wsp-preview__time">
uploaded {lastUpdatedLabel(preview.uploadedAt, new Date())}
</div>
)}
</div>
{"label" in previewPr ? (
<PrLabel size="sm" {...previewPr.label} />
) : (
<>
<GhKindIcon kind={preview.kind} /> {previewPr.text}
</>
)}
</div>
)}
{details.length > 0 && (
<dl className="wsp-preview__details">
{details.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
)}
</div>
) : null;

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/shell/SidebarUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { readWorkspaceSnapshot } from "../../lib/workspace-cache";
import { loadWorkspaceSummary } from "../../lib/workspace-summary-source";
import {
BYO_USAGE_NOTE,
objectCountLabel,
usageMeters,
usagePlainText,
type UsageMeter,
Expand Down Expand Up @@ -144,7 +145,7 @@ export function SidebarUsage({
))}
{open && (
<p className="m-0 text-xs text-muted-foreground">
{`${state.usage.objects} object${state.usage.objects === 1 ? "" : "s"}`}
{objectCountLabel(state.usage.objects)}
{state.usage.storageBudgetBasis === "shared" ? ` · ${BYO_USAGE_NOTE}` : ""}
</p>
)}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/workspace-screenshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,22 @@ describe("shotPreviewCaption", () => {
expect(cap.ref).toBeUndefined();
expect(cap.pr).toBeUndefined();
});

it("adds page, state and device for the hover card", () => {
expect(
shotPreviewCaption({
key: "shot.png",
state: "after",
metadata: { path: "/settings", "ai.surface": "mobile" },
}),
).toEqual({ name: "shot.png", page: "/settings", state: "After", device: "Mobile" });
expect(
shotPreviewCaption({
key: "shot.png",
metadata: { screen: "Login", state: "before", "ai.surface": "watch" },
}),
).toEqual({ name: "shot.png", page: "Login", state: "Before" });
});
});

describe("projectLabelFromItemMeta", () => {
Expand Down
37 changes: 30 additions & 7 deletions apps/web/src/lib/workspace-screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { asPrState, type PrLabelInput } from "@uploads/ui/lib/pr-label";
import type { GithubTitleMap } from "./api-client";
import {
FILE_SURFACE_LABELS,
filesBasePath,
parseFileTypeParam,
parseSinceParam,
Expand Down Expand Up @@ -117,20 +118,36 @@ export function leafName(key: string): string {
return (slash === -1 ? trimmed : trimmed.slice(slash + 1)) || key;
}

/** Hover-card details for a tile. */
export interface ShotPreviewCaption {
name: string;
pr?: string;
ref?: string;
uploadedAt?: string;
/** `path`, else `screen`. */
page?: string;
/** "Before" / "After". */
state?: string;
/** From the classifier's `ai.surface`: "Mobile" / "Desktop" / "Tablet". */
device?: string;
}

/**
* Hover-preview caption: filename, plus a compact PR/issue line when the
* file carries GitHub attachment metadata. Mirrors the file page's
* filename + byline split without the native `title` tooltip.
* Hover-card caption: filename, a compact PR/issue line when the file carries
* GitHub attachment metadata, and the page, state and device when known.
* Mirrors the file page's filename + byline split without the native `title`
* tooltip.
*/
export function shotPreviewCaption(item: {
key: string;
state?: string;
ghKind?: string;
ghNumber?: string;
ghRef?: string;
updatedAt?: string;
uploadedAt?: string;
metadata?: Record<string, string>;
}): { name: string; pr?: string; ref?: string; uploadedAt?: string } {
}): ShotPreviewCaption {
const kind = item.ghKind ?? item.metadata?.["gh.kind"];
const number = item.ghNumber ?? item.metadata?.["gh.number"];
const repo = item.metadata?.["gh.repo"];
Expand All @@ -144,12 +161,18 @@ export function shotPreviewCaption(item: {
if (kind === "pull") pr = `PR #${number}`;
else if (kind === "issue" || kind === "issues") pr = `Issue #${number}`;
}
const caption: { name: string; pr?: string; ref?: string; uploadedAt?: string } = {
name: leafName(item.key),
};
const caption: ShotPreviewCaption = { name: leafName(item.key) };
if (pr) caption.pr = pr;
if (pr && ref) caption.ref = ref;
if (uploadedAt) caption.uploadedAt = uploadedAt;
const page = item.metadata?.path || item.metadata?.screen;
if (page) caption.page = page;
const state = (item.state ?? item.metadata?.state)?.toLowerCase();
if (state === "before" || state === "after") {
caption.state = state === "before" ? "Before" : "After";
}
const surface = parseSurfaceParam(item.metadata?.["ai.surface"]);
if (surface) caption.device = FILE_SURFACE_LABELS[surface];
return caption;
}

Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/lib/workspace-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,21 @@ export type UsageSnapshot = {
storageBudgetBasis?: "total" | "shared";
};

/** Counts with grouped digits: 10,000, not 10000. */
export function formatCount(n: number): string {
return n.toLocaleString("en-US");
}

/** "1 object" / "12,345 objects". */
export function objectCountLabel(n: number): string {
return `${formatCount(n)} object${n === 1 ? "" : "s"}`;
}

function formatUsagePlain(usage: UsageSnapshot): string {
const parts = [
formatBytes(usage.bytes),
`${usage.objects} object${usage.objects === 1 ? "" : "s"}`,
];
if (usage.uploadsInPeriod > 0) parts.push(`${usage.uploadsInPeriod} uploads this month`);
const parts = [formatBytes(usage.bytes), objectCountLabel(usage.objects)];
if (usage.uploadsInPeriod > 0) {
parts.push(`${formatCount(usage.uploadsInPeriod)} uploads this month`);
}
return parts.join(" · ");
}

Expand Down Expand Up @@ -138,7 +147,7 @@ export function usageMeters(usage: UsageSnapshot): UsageMeter[] {
if (uploadsPct !== null && usage.maxUploadsPerPeriod) {
meters.push({
label: "Monthly uploads",
detail: `${usage.uploadsInPeriod.toLocaleString("en-US")} of ${usage.maxUploadsPerPeriod.toLocaleString("en-US")}`,
detail: `${formatCount(usage.uploadsInPeriod)} of ${formatCount(usage.maxUploadsPerPeriod)}`,
pct: uploadsPct,
});
}
Expand All @@ -162,7 +171,7 @@ export function renderUsageHtml(usage: UsageSnapshot): string {
if (!meters.length) {
return `<div class="usage-text">${escapeHtml(formatUsagePlain(usage))}</div>${byoNote}`;
}
const objects = `${usage.objects} object${usage.objects === 1 ? "" : "s"}`;
const objects = objectCountLabel(usage.objects);
return `<div class="ul-progress">${meters.map((m) => progressRowHtml(m.label, m.detail, m.pct)).join("")}</div><div class="usage-meta">${escapeHtml(objects)}</div>${byoNote}`;
}

Expand Down
Loading
Loading