fix: Fully load thumbnail before save (#2307)

Fixes https://github.com/webrecorder/browsertrix/issues/2306

## Changes

Refactors collection view configuration to wait for thumbnail preview
image (using `URL.createObjectURL`, like in QA screenshots) to be fully
loaded from `replay-web-page` before saving.
This commit is contained in:
sua yoo 2025-01-15 22:58:32 -08:00 committed by GitHub
parent 4583babecb
commit a64f3a6c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 220 additions and 74 deletions

View File

@ -6,16 +6,14 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import queryString from "query-string";
import {
HomeView,
type CollectionSnapshotPreview,
} from "./collection-snapshot-preview";
import type { SelectSnapshotDetail } from "./select-collection-start-page";
import { BtrixElement } from "@/classes/BtrixElement";
import type { Dialog } from "@/components/ui/dialog";
import { formatRwpTimestamp } from "@/utils/replay";
enum HomeView {
Pages = "pages",
URL = "url",
}
/**
* @fires btrix-change
@ -76,11 +74,11 @@ export class CollectionStartPageDialog extends BtrixElement {
private readonly form?: HTMLFormElement | null;
@query("#thumbnailPreview")
private readonly thumbnailPreview?: HTMLIFrameElement | null;
private readonly thumbnailPreview?: CollectionSnapshotPreview | null;
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("homeUrl") && this.homeUrl) {
this.homeView = HomeView.URL;
if (changedProperties.has("homeUrl")) {
this.homeView = this.homeUrl ? HomeView.URL : HomeView.Pages;
}
}
@ -89,7 +87,7 @@ export class CollectionStartPageDialog extends BtrixElement {
this.homeView === HomeView.URL && !this.selectedSnapshot;
return html`
<btrix-dialog
.label=${msg("Configure Replay Home")}
.label=${msg("Configure Replay View")}
.open=${this.open}
class="[--width:60rem]"
@sl-show=${() => (this.showContent = true)}
@ -142,11 +140,6 @@ export class CollectionStartPageDialog extends BtrixElement {
}
private renderPreview() {
let urlPreview = html`
<p class="m-3 text-pretty text-neutral-500">
${msg("Enter a Page URL to preview it")}
</p>
`;
const snapshot =
this.selectedSnapshot ||
(this.homeUrl
@ -154,22 +147,19 @@ export class CollectionStartPageDialog extends BtrixElement {
url: this.homeUrl,
ts: this.homeTs,
pageId: this.homePageId,
status: 200,
}
: null);
if (snapshot) {
urlPreview = html`
<sl-tooltip hoist>
<iframe
class="inline-block size-full"
id="thumbnailPreview"
src=${`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`}
>
</iframe>
<span slot="content" class="break-all">${snapshot.url}</span>
</sl-tooltip>
`;
}
const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
// TODO Get query from replay-web-page embed
const query = queryString.stringify({
source: replaySource,
customColl: this.collectionId,
embed: "default",
noCache: 1,
noSandbox: 1,
});
return html`
<div
@ -177,13 +167,15 @@ export class CollectionStartPageDialog extends BtrixElement {
? "flex items-center justify-center"
: ""} relative aspect-video overflow-hidden rounded-lg border bg-slate-50"
>
${when(
this.homeView === HomeView.URL && this.replayLoaded,
() => urlPreview,
)}
<div class="${this.homeView === HomeView.URL ? "offscreen" : ""}">
${this.renderReplay()}
</div>
<btrix-collection-snapshot-preview
class="contents"
id="thumbnailPreview"
collectionId=${this.collectionId || ""}
view=${this.homeView}
replaySrc=${`/replay/?${query}#view=pages`}
.snapshot=${snapshot}
>
</btrix-collection-snapshot-preview>
${when(
!this.replayLoaded,
@ -281,34 +273,18 @@ export class CollectionStartPageDialog extends BtrixElement {
`;
}
private renderReplay() {
const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
// TODO Get query from replay-web-page embed
const query = queryString.stringify({
source: replaySource,
customColl: this.collectionId,
embed: "default",
noCache: 1,
noSandbox: 1,
});
return html`<div class="aspect-video w-[200%]">
<div class="pointer-events-none aspect-video origin-top-left scale-50">
<iframe
class="inline-block size-full"
src=${`/replay/?${query}#view=pages`}
></iframe>
</div>
</div>`;
}
private async onSubmit(e: SubmitEvent) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const { homeView, useThumbnail } = serialize(form);
if (homeView === HomeView.Pages && !this.homePageId) {
if (
(homeView === HomeView.Pages && !this.homePageId) ||
(homeView === HomeView.URL &&
this.selectedSnapshot &&
this.homePageId === this.selectedSnapshot.pageId)
) {
// No changes to save
this.open = false;
return;
@ -322,8 +298,6 @@ export class CollectionStartPageDialog extends BtrixElement {
(homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null,
});
this.dispatchEvent(new CustomEvent("btrix-change"));
const shouldUpload =
homeView === HomeView.URL &&
useThumbnail === "on" &&
@ -333,19 +307,13 @@ export class CollectionStartPageDialog extends BtrixElement {
const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`;
let file: File | undefined;
if (shouldUpload && this.thumbnailPreview?.src) {
const { src } = this.thumbnailPreview;
// Wait to get the thumbnail image before closing the dialog
try {
const resp = await this.thumbnailPreview.contentWindow!.fetch(src);
const blob = await resp.blob();
if (shouldUpload && this.thumbnailPreview) {
const blob = await this.thumbnailPreview.thumbnailBlob;
if (blob) {
file = new File([blob], fileName, {
type: blob.type,
});
} catch (err) {
console.debug(err);
}
} else {
this.notify.toast({
@ -387,6 +355,8 @@ export class CollectionStartPageDialog extends BtrixElement {
});
}
}
this.dispatchEvent(new CustomEvent("btrix-change"));
} catch (err) {
console.debug(err);

View File

@ -0,0 +1,172 @@
import { localized, msg } from "@lit/localize";
import { Task } from "@lit/task";
import clsx from "clsx";
import { html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { SelectSnapshotDetail } from "./select-collection-start-page";
import { TailwindElement } from "@/classes/TailwindElement";
import { formatRwpTimestamp } from "@/utils/replay";
import { tw } from "@/utils/tailwind";
export enum HomeView {
Pages = "pages",
URL = "url",
}
/**
* Display preview of page snapshot.
*
* A previously loaded `replay-web-page` embed is required in order for preview to work.
*/
@customElement("btrix-collection-snapshot-preview")
@localized()
export class CollectionSnapshotPreview extends TailwindElement {
@property({ type: String })
collectionId = "";
@property({ type: String })
replaySrc = "";
@property({ type: String })
view?: HomeView;
@property({ type: Object })
snapshot?: SelectSnapshotDetail["item"];
@query("iframe")
private readonly iframe?: HTMLIFrameElement | null;
@state()
private iframeLoaded = false;
public get thumbnailBlob() {
return this.blobTask.taskComplete.finally(() => this.blobTask.value);
}
private readonly blobTask = new Task(this, {
task: async ([collectionId, snapshot, iframeLoaded]) => {
if (
!collectionId ||
!snapshot ||
!iframeLoaded ||
!this.iframe?.contentWindow
) {
return;
}
const resp = await this.iframe.contentWindow.fetch(
`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`,
);
if (resp.status === 200) {
return await resp.blob();
}
throw new Error(`couldn't get thumbnail`);
},
args: () => [this.collectionId, this.snapshot, this.iframeLoaded] as const,
});
private readonly objectUrlTask = new Task(this, {
task: ([blob]) => {
if (!blob) return "";
const url = URL.createObjectURL(blob);
if (url) return url;
throw new Error("no object url");
},
args: () => [this.blobTask.value] as const,
});
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.objectUrlTask.value) {
URL.revokeObjectURL(this.objectUrlTask.value);
}
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has("collectionId") ||
changedProperties.has("snapshot")
) {
if (this.objectUrlTask.value) {
URL.revokeObjectURL(this.objectUrlTask.value);
}
}
}
render() {
return html` ${this.renderSnapshot()} ${this.renderReplay()} `;
}
private renderSnapshot() {
if (this.view === HomeView.Pages) return;
return this.blobTask.render({
complete: this.renderImage,
pending: this.renderSpinner,
error: this.renderError,
});
}
private readonly renderImage = () => {
if (!this.snapshot) {
return html`
<p class="m-3 text-pretty text-neutral-500">
${msg("Enter a Page URL to preview it")}
</p>
`;
}
return html`
<div class="size-full">
<sl-tooltip hoist>
${this.objectUrlTask.render({
complete: (value) =>
value
? html`<img class="size-full" src=${value} />`
: this.renderSpinner(),
pending: () => "pending",
})}
<span slot="content" class="break-all">${this.snapshot.url}</span>
</sl-tooltip>
</div>
`;
};
private renderReplay() {
return html`<div
class=${clsx(tw`size-full`, this.view === HomeView.URL && tw`offscreen`)}
>
<div class="aspect-video w-[200%]">
<div class="pointer-events-none aspect-video origin-top-left scale-50">
<iframe
class="inline-block size-full"
src=${this.replaySrc}
@load=${() => {
this.iframeLoaded = true;
}}
></iframe>
</div>
</div>
</div>`;
}
private readonly renderError = () => html`
<p class="m-3 text-pretty text-danger">
${msg("Couldn't load preview. Try another snapshot")}
</p>
`;
private readonly renderSpinner = () => html`
<div class="flex size-full items-center justify-center text-2xl">
<sl-spinner></sl-spinner>
</div>
`;
}

View File

@ -246,7 +246,7 @@ export class ShareCollection extends BtrixElement {
@sl-after-hide=${() => {
this.tabGroup?.show(Tab.Link);
}}
class="[--width:40rem] [--body-spacing:0]"
class="[--body-spacing:0] [--width:40rem]"
>
<sl-tab-group>
<sl-tab slot="nav" panel=${Tab.Link}

View File

@ -167,10 +167,14 @@ export class CollectionDetail extends BtrixElement {
<sl-button
size="small"
@click=${() => (this.openDialogName = "editStartPage")}
?disabled=${!this.collection?.crawlCount}
?disabled=${!this.collection?.crawlCount ||
!this.isRwpLoaded}
>
<sl-icon name="house-gear" slot="prefix"></sl-icon>
${msg("Configure Home")}
${!this.collection ||
Boolean(this.collection.crawlCount && !this.isRwpLoaded)
? html`<sl-spinner slot="prefix"></sl-spinner>`
: html`<sl-icon name="gear" slot="prefix"></sl-icon>`}
${msg("Configure View")}
</sl-button>
</sl-tooltip>
`,
@ -400,8 +404,8 @@ export class CollectionDetail extends BtrixElement {
}}
?disabled=${!this.collection?.crawlCount}
>
<sl-icon name="house-gear" slot="prefix"></sl-icon>
${msg("Configure Replay Home")}
<sl-icon name="gear" slot="prefix"></sl-icon>
${msg("Configure Replay View")}
</sl-menu-item>
<sl-menu-item
@click=${async () => {