browsertrix/frontend/src/components/profile-browser.ts

404 lines
10 KiB
TypeScript

// import { LitElement, html } from "lit";
import { property, state, query } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
const POLL_INTERVAL_SECONDS = 2;
const hiddenClassList = ["translate-x-2/3", "opacity-0", "pointer-events-none"];
/**
* View embedded profile browser
*
* Usage example:
* ```ts
* <btrix-profile-browser
* authState=${authState}
* orgId=${orgId}
* browserId=${browserId}
* initialNavigateUrl=${initialNavigateUrl}
* origins=${origins}
* ></btrix-profile-browser>
* ```
*
* @event load Event on iframe load, with src URL
*/
@localized()
export class ProfileBrowser extends LiteElement {
@property({ type: Object })
authState!: AuthState;
@property({ type: String })
orgId!: string;
@property({ type: String })
browserId?: string;
@property({ type: String })
initialNavigateUrl?: string;
@property({ type: Array })
origins?: string[];
@state()
private iframeSrc?: string;
@state()
private isIframeLoaded = false;
@state()
private hasFetchError = false;
@state()
private isFullscreen = false;
@state()
private showOriginSidebar = false;
@state()
private newOrigins: string[] = [];
@query("#profileBrowserSidebar")
private sidebar?: HTMLElement;
@query("#iframeWrapper")
private iframeWrapper?: HTMLElement;
@query("iframe")
private iframe?: HTMLIFrameElement;
private pollTimerId?: number;
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
}
disconnectedCallback() {
window.clearTimeout(this.pollTimerId);
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("browserId")) {
if (this.browserId) {
window.clearTimeout(this.pollTimerId);
this.fetchBrowser();
} else if (changedProperties.get("browserId")) {
this.iframeSrc = undefined;
this.isIframeLoaded = false;
window.clearTimeout(this.pollTimerId);
}
}
if (
changedProperties.has("showOriginSidebar") &&
changedProperties.get("showOriginSidebar") !== undefined
) {
this.animateSidebar();
}
}
private animateSidebar() {
if (!this.sidebar) return;
if (this.showOriginSidebar) {
this.sidebar.classList.remove(...hiddenClassList);
} else {
this.sidebar.classList.add(...hiddenClassList);
}
}
render() {
return html`
<div id="interactive-browser" class="w-full h-full flex flex-col">
${this.renderControlBar()}
<div
id="iframeWrapper"
class="${this.isFullscreen
? "w-screen h-screen"
: "border-t"} flex-1 relative bg-neutral-50 overflow-hidden"
aria-live="polite"
>
${this.renderBrowser()}
<div
id="profileBrowserSidebar"
class="${hiddenClassList.join(
" "
)} lg:absolute top-0 right-0 bottom-0 lg:w-80 lg:p-3 flex transition-all duration-300 ease-out"
>
<div
class="shadow-lg overflow-auto border rounded-lg bg-white flex-1"
>
${this.renderOrigins()} ${this.renderNewOrigins()}
</div>
</div>
</div>
</div>
`;
}
private renderControlBar() {
if (this.isFullscreen) {
return html`
<div
class="fixed top-2 left-1/2 bg-white rounded-lg shadow z-50 -translate-x-1/2 flex items-center text-base"
>
${this.renderSidebarButton()}
<sl-icon-button
name="fullscreen-exit"
@click=${() => document.exitFullscreen()}
></sl-icon-button>
</div>
`;
}
return html`
<div class="text-right text-base p-1">
${this.renderSidebarButton()}
<sl-icon-button
name="arrows-fullscreen"
@click=${() => this.enterFullscreen("interactive-browser")}
></sl-icon-button>
</div>
`;
}
private renderBrowser() {
if (this.hasFetchError) {
return html`
<btrix-alert variant="danger">
${msg(`The interactive browser is not available.`)}
</btrix-alert>
`;
}
if (this.iframeSrc) {
return html`<iframe
class="w-full h-full"
title=${msg("Interactive browser for creating browser profile")}
src=${this.iframeSrc}
@load=${this.onIframeLoad}
></iframe>`;
}
if (this.browserId && !this.isIframeLoaded) {
return html`
<div class="w-full h-full flex items-center justify-center text-3xl">
<sl-spinner></sl-spinner>
</div>
`;
}
return "";
}
private renderSidebarButton() {
return html`
<sl-icon-button
name="layout-sidebar-reverse"
class="${this.showOriginSidebar ? "text-blue-600" : ""}"
@click=${() => (this.showOriginSidebar = !this.showOriginSidebar)}
></sl-icon-button>
`;
}
private renderOrigins() {
return html`
<h4 class="text-xs text-neutral-500 leading-tight p-2 border-b">
<span class="inline-block align-middle">${msg("Visited Sites")}</span>
<sl-tooltip content=${msg("Websites in the browser profile")}
><sl-icon
class="inline-block align-middle"
name="info-circle"
></sl-icon
></sl-tooltip>
</h4>
<ul>
${this.origins?.map((url) => this.renderOriginItem(url))}
</ul>
`;
}
private renderNewOrigins() {
if (!this.newOrigins.length) return;
return html`
<h4 class="text-xs text-neutral-500 leading-tight p-2 border-b">
<span class="inline-block align-middle">${msg("New Sites")}</span>
<sl-tooltip
content=${msg(
"Websites that are not in the browser profile yet. Finish editing and save to add these websites to the profile."
)}
><sl-icon
class="inline-block align-middle"
name="info-circle"
></sl-icon
></sl-tooltip>
</h4>
<ul>
${this.newOrigins.map((url) => this.renderOriginItem(url))}
</ul>
`;
}
private renderOriginItem(url: string) {
return html`<li
class="p-2 flex items-center justify-between border-t first:border-t-0 border-t-neutral-100${this
.iframeSrc
? " hover:bg-slate-50 hover:text-primary"
: ""}"
role=${this.iframeSrc ? "button" : "listitem"}
title=${msg(str`Go to ${url}`)}
@click=${() => (this.iframeSrc ? this.navigateBrowser({ url }) : {})}
>
<div class="text-sm truncate w-full">${url}</div>
${this.iframeSrc
? html`<sl-icon name="play-btn" class="text-xl"></sl-icon>`
: ""}
</li>`;
}
/**
* Fetch browser profile and update internal state
*/
private async fetchBrowser(): Promise<void> {
await this.updateComplete;
this.iframeSrc = undefined;
this.isIframeLoaded = false;
try {
await this.checkBrowserStatus();
} catch (e) {
this.hasFetchError = true;
this.notify({
message: msg("Sorry, couldn't create browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
/**
* Check whether temporary browser is up
**/
private async checkBrowserStatus() {
const result = await this.getBrowser();
if (result.detail === "waiting_for_browser") {
this.pollTimerId = window.setTimeout(
() => this.checkBrowserStatus(),
POLL_INTERVAL_SECONDS * 1000
);
return;
} else if (result.url) {
if (this.initialNavigateUrl) {
await this.navigateBrowser({ url: this.initialNavigateUrl });
}
this.iframeSrc = result.url;
await this.updateComplete;
this.dispatchEvent(new CustomEvent("load", { detail: result.url }));
this.pingBrowser();
} else {
console.debug("Unknown checkBrowserStatus state");
}
}
private async getBrowser(): Promise<{
detail?: string;
url?: string;
}> {
const data = await this.apiFetch(
`/orgs/${this.orgId}/profiles/browser/${this.browserId}`,
this.authState!
);
return data;
}
/**
* Navigate to URL in temporary browser
**/
private async navigateBrowser({ url }: { url: string }) {
if (!this.iframeSrc) return;
const data = this.apiFetch(
`/orgs/${this.orgId}/profiles/browser/${this.browserId}/navigate`,
this.authState!,
{
method: "POST",
body: JSON.stringify({ url }),
}
);
return data;
}
/**
* Ping temporary browser every minute to keep it alive
**/
private async pingBrowser() {
if (!this.iframeSrc) return;
const data = await this.apiFetch(
`/orgs/${this.orgId}/profiles/browser/${this.browserId}/ping`,
this.authState!,
{
method: "POST",
}
);
if (!this.origins) {
this.origins = data.origins;
} else {
this.newOrigins = data.origins?.filter(
(url: string) => !this.origins?.includes(url)
);
}
this.pollTimerId = window.setTimeout(
() => this.pingBrowser(),
POLL_INTERVAL_SECONDS * 1000
);
}
/**
* Enter fullscreen mode
* @param id ID of element to fullscreen
*/
private async enterFullscreen(id: string) {
try {
document.getElementById(id)!.requestFullscreen({
// Hide browser navigation controls
navigationUI: "hide",
});
} catch (err) {
console.error(err);
}
}
private onIframeLoad() {
this.isIframeLoaded = true;
try {
this.iframe?.contentWindow?.localStorage.setItem("uiTheme", '"default"');
} catch (e) {}
this.dispatchEvent(new CustomEvent("load", { detail: this.iframeSrc }));
}
private onFullscreenChange = async () => {
if (document.fullscreenElement) {
this.isFullscreen = true;
} else {
this.isFullscreen = false;
}
};
}