Create browser profile UI (#211)

This commit is contained in:
sua yoo 2022-04-13 21:11:13 -07:00 committed by GitHub
parent d2653ae835
commit f5993e8ad8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 552 additions and 17 deletions

View File

@ -431,6 +431,7 @@ export class App extends LiteElement {
case "archiveNewResourceTab":
case "archiveCrawl":
case "browserProfile":
case "browser":
case "crawlTemplate":
case "crawlTemplateEdit":
return html`<btrix-archive
@ -444,6 +445,7 @@ export class App extends LiteElement {
archiveId=${this.viewState.params.id}
archiveTab=${this.viewState.params.tab as ArchiveTab}
browserProfileId=${this.viewState.params.browserProfileId}
browserId=${this.viewState.params.browserId}
crawlConfigId=${this.viewState.params.crawlConfigId}
crawlId=${this.viewState.params.crawlId}
?isAddingMember=${this.viewState.route === "archiveAddMember"}

View File

@ -8,7 +8,10 @@ import { Profile } from "./types";
/**
* Usage:
* ```ts
* <btrix-browser-profiles-list></btrix-browser-profiles-list>
* <btrix-browser-profiles-list
* authState=${authState}
* archiveId=${archiveId}
* ></btrix-browser-profiles-list>
* ```
*/
@localized()
@ -17,20 +20,74 @@ export class BrowserProfilesList extends LiteElement {
authState!: AuthState;
@property({ type: String })
archiveId?: string;
archiveId!: string;
@property({ type: Boolean })
showCreateDialog = false;
@state()
browserProfiles?: Profile[];
@state()
private isCreateFormVisible = false;
@state()
private isSubmitting = false;
/** Profile creation only works in Chromium-based browsers */
private isBrowserCompatible = Boolean((window as any).chrome);
firstUpdated() {
this.fetchCrawls();
if (this.showCreateDialog) {
this.isCreateFormVisible = true;
}
this.fetchBrowserProfiles();
}
render() {
return html` ${this.renderTable()} `;
return html`<header class="mb-3 text-right">
<a
href=${`/archives/${this.archiveId}/browser-profiles/new`}
class="inline-block bg-primary hover:bg-indigo-400 text-white text-center font-medium leading-none rounded px-3 py-2 transition-colors"
role="button"
@click=${this.navLink}
>
<sl-icon
class="inline-block align-middle mr-2"
name="plus-lg"
></sl-icon
><span class="inline-block align-middle mr-2 text-sm"
>${msg("New Browser Profile")}</span
>
</a>
</header>
${this.renderTable()}
<sl-dialog
label=${msg(str`New Browser Profile`)}
?open=${this.showCreateDialog}
@sl-request-close=${this.hideDialog}
@sl-show=${() => (this.isCreateFormVisible = true)}
@sl-after-hide=${() => (this.isCreateFormVisible = false)}
>
${this.isBrowserCompatible
? ""
: html`
<div class="mb-4">
<btrix-alert type="warning" class="text-sm">
${msg(
"Browser profile creation is only supported in Chromium-based browsers (such as Chrome) at this time. Please re-open this page in a compatible browser to proceed."
)}
</btrix-alert>
</div>
`}
${this.isCreateFormVisible ? this.renderCreateForm() : ""}
</sl-dialog> `;
}
renderTable() {
private renderTable() {
return html`
<div role="table">
<div class="mb-2 px-4" role="rowgroup">
@ -90,10 +147,132 @@ export class BrowserProfilesList extends LiteElement {
`;
}
private renderCreateForm() {
return html`<sl-form @sl-submit=${this.onSubmit}>
<div class="grid gap-5">
<div>
<label
id="startingUrlLabel"
class="text-sm leading-normal"
style="margin-bottom: var(--sl-spacing-3x-small)"
>${msg("Starting URL")}
</label>
<div class="flex">
<sl-select
class="grow-0 mr-1"
name="urlPrefix"
value="https://"
hoist
?disabled=${!this.isBrowserCompatible}
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
>
<sl-menu-item value="http://">http://</sl-menu-item>
<sl-menu-item value="https://">https://</sl-menu-item>
</sl-select>
<sl-input
class="grow"
name="url"
placeholder=${msg("example.com")}
autocomplete="off"
aria-labelledby="startingUrlLabel"
?disabled=${!this.isBrowserCompatible}
required
>
</sl-input>
</div>
</div>
<details>
<summary class="text-sm text-neutral-500 font-medium cursor-pointer">
${msg("More options")}
</summary>
<div class="p-3">
<sl-select
name="baseId"
label=${msg("Extend Profile")}
help-text=${msg("Extend an existing browser profile.")}
clearable
?disabled=${!this.isBrowserCompatible}
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
>
${this.browserProfiles?.map(
(profile) => html`
<sl-menu-item value=${profile.id}
>${profile.name}</sl-menu-item
>
`
)}
</sl-select>
</div>
</details>
<div class="text-right">
<sl-button @click=${this.hideDialog}>${msg("Cancel")}</sl-button>
<sl-button
type="primary"
submit
?disabled=${!this.isBrowserCompatible || this.isSubmitting}
?loading=${this.isSubmitting}
>
${msg("Start Browser")}
</sl-button>
</div>
</div>
</sl-form>`;
}
private hideDialog() {
this.navTo(`/archives/${this.archiveId}/browser-profiles`);
}
async onSubmit(event: { detail: { formData: FormData } }) {
this.isSubmitting = true;
const { formData } = event.detail;
const url = formData.get("url") as string;
const params = {
url: `${formData.get("urlPrefix")}${url.substring(url.indexOf(",") + 1)}`,
baseId: formData.get("baseId"),
};
try {
const data = await this.apiFetch(
`/archives/${this.archiveId}/profiles/browser`,
this.authState!,
{
method: "POST",
body: JSON.stringify(params),
}
);
this.notify({
message: msg("Starting up browser for profile creation."),
type: "success",
icon: "check2-circle",
});
this.navTo(
`/archives/${this.archiveId}/browser-profiles/profile/browser/${data.browserid}`
);
} catch (e) {
this.isSubmitting = false;
this.notify({
message: msg("Sorry, couldn't create browser profile at this time."),
type: "danger",
icon: "exclamation-octagon",
});
}
}
/**
* Fetch browser profiles and update internal state
*/
private async fetchCrawls(): Promise<void> {
private async fetchBrowserProfiles(): Promise<void> {
try {
const data = await this.getProfiles();
@ -108,10 +287,6 @@ export class BrowserProfilesList extends LiteElement {
}
private async getProfiles(): Promise<Profile[]> {
if (!this.archiveId) {
throw new Error(`Archive ID ${typeof this.archiveId}`);
}
const data = await this.apiFetch(
`/archives/${this.archiveId}/profiles`,
this.authState!
@ -119,6 +294,15 @@ export class BrowserProfilesList extends LiteElement {
return data;
}
/**
* Stop propgation of sl-select events.
* Prevents bug where sl-dialog closes when dropdown closes
* https://github.com/shoelace-style/shoelace/issues/170
*/
private stopProp(e: CustomEvent) {
e.stopPropagation();
}
}
customElements.define("btrix-browser-profiles-list", BrowserProfilesList);

View File

@ -0,0 +1,337 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import { ref } from "lit/directives/ref.js";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
/**
* Usage:
* ```ts
* <btrix-browser-profiles-new
* authState=${authState}
* archiveId=${archiveId}
* browserId=${browserId}
* ></btrix-browser-profiles-new>
* ```
*/
@localized()
export class BrowserProfilesNew extends LiteElement {
@property({ type: Object })
authState!: AuthState;
@property({ type: String })
archiveId!: string;
@property({ type: String })
browserId!: string;
@state()
private browserUrl?: string;
@state()
private isSubmitting = false;
@state()
private hasFetchError = false;
@state()
private isFullscreen = false;
private pollTimerId?: number;
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
}
disconnectedCallback() {
window.clearTimeout(this.pollTimerId);
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
firstUpdated() {
this.fetchBrowser();
}
render() {
return html`
<div id="browserProfileInstructions" class="mb-5">
<p class="text-sm text-neutral-500">
${msg(
"Interact with the browser to record your browser profile. When youre finished interacting, name and save the profile."
)}
</p>
</div>
<div id="interactive-browser" aria-live="polite">
${this.hasFetchError
? html`
<btrix-alert type="danger">
${msg(
html`The interactive browser is not available. Try creating a
new browser profile.
<a
class="font-medium underline"
href=${`/archives/${this.archiveId}/browser-profiles/new`}
@click=${this.navLink}
>Create New</a
>`
)}
</btrix-alert>
`
: html`
<div class="lg:flex bg-white">
<div class="grow lg:rounded-l overflow-hidden">
${this.browserUrl
? this.renderBrowser()
: html`
<div
class="aspect-video bg-slate-50 flex items-center justify-center text-4xl"
>
<sl-spinner></sl-spinner>
</div>
`}
</div>
<div
class="rounded-b lg:rounded-b-none lg:rounded-r border p-2 shadow-inner"
>
${document.fullscreenEnabled
? html`
<div class="mb-4 text-right">
<sl-button
type="neutral"
size="small"
@click=${() =>
this.isFullscreen
? document.exitFullscreen()
: this.enterFullscreen("interactive-browser")}
>
${this.isFullscreen
? html`
<sl-icon
slot="prefix"
name="fullscreen-exit"
label=${msg("Exit fullscreen")}
></sl-icon>
${msg("Exit")}
`
: html`
<sl-icon
slot="prefix"
name="arrows-fullscreen"
label=${msg("Fullscreen")}
></sl-icon>
${msg("Go Fullscreen")}
`}
</sl-button>
</div>
`
: ""}
<div class="p-2">${this.renderForm()}</div>
</div>
</div>
`}
</div>
`;
}
private renderForm() {
return html`<sl-form @sl-submit=${this.onSubmit}>
<div class="grid gap-5">
<sl-input
name="name"
label=${msg("Name")}
placeholder=${msg("Example (example.com)", {
desc: "Example browser profile name",
})}
autocomplete="off"
value="My Profile"
?disabled=${!this.browserUrl}
required
></sl-input>
<sl-textarea
name="description"
label=${msg("Description")}
help-text=${msg("Optional profile description")}
placeholder=${msg("Example (example.com) login profile", {
desc: "Example browser profile name",
})}
rows="2"
autocomplete="off"
?disabled=${!this.browserUrl}
></sl-textarea>
<div class="text-right">
<sl-button
type="primary"
submit
?disabled=${!this.browserUrl || this.isSubmitting}
?loading=${this.isSubmitting}
>
${msg("Save Profile")}
</sl-button>
</div>
</div>
</sl-form>`;
}
private renderBrowser() {
return html`
<iframe
class="w-full ${this.isFullscreen ? "h-screen" : "aspect-video"}"
title=${msg("Interactive browser for creating browser profile")}
src=${this.browserUrl!}
${ref((el) => this.onIframeRef(el as HTMLIFrameElement))}
></iframe>
`;
}
/**
* Fetch browser profiles and update internal state
*/
private async fetchBrowser(): Promise<void> {
try {
await this.checkBrowserStatus();
} catch (e) {
this.hasFetchError = true;
this.notify({
message: msg("Sorry, couldn't create browser profile at this time."),
type: "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(),
5 * 1000
);
} else if (result.url) {
this.browserUrl = result.url;
this.pingBrowser();
} else {
console.debug("Unknown checkBrowserStatus state");
}
}
private async getBrowser(): Promise<{
detail?: string;
url?: string;
}> {
const data = await this.apiFetch(
`/archives/${this.archiveId}/profiles/browser/${this.browserId}`,
this.authState!
);
return data;
}
/**
* Ping temporary browser every minute to keep it alive
**/
private async pingBrowser() {
await this.apiFetch(
`/archives/${this.archiveId}/profiles/browser/${this.browserId}/ping`,
this.authState!,
{
method: "POST",
}
);
this.pollTimerId = window.setTimeout(() => this.pingBrowser(), 60 * 1000);
}
private async onSubmit(event: { detail: { formData: FormData } }) {
this.isSubmitting = true;
if (this.isFullscreen) {
await document.exitFullscreen();
}
const { formData } = event.detail;
const params = {
name: formData.get("name"),
description: formData.get("description"),
};
try {
const data = await this.apiFetch(
`/archives/${this.archiveId}/profiles/browser/${this.browserId}/commit`,
this.authState!,
{
method: "POST",
body: JSON.stringify(params),
}
);
this.notify({
message: msg("Successfully created browser profile."),
type: "success",
icon: "check2-circle",
});
// TODO nav to detail page
// this.navTo(
// `/archives/${this.archiveId}/browser-profiles/profile/${data.id}`
// );
this.navTo(`/archives/${this.archiveId}/browser-profiles`);
} catch (e) {
this.isSubmitting = false;
this.notify({
message: msg("Sorry, couldn't create browser profile at this time."),
type: "danger",
icon: "exclamation-octagon",
});
}
}
private onIframeRef(el: HTMLIFrameElement) {
el.addEventListener("load", () => {
// TODO see if we can make this work locally without CORs errors
el.contentWindow?.localStorage.setItem("uiTheme", '"default"');
el.contentWindow?.localStorage.setItem(
"InspectorView.screencastSplitViewState",
'{"vertical":{"size":241}}'
);
});
}
/**
* Enter fullscreen mode
* @param id ID of element to fullscreen
*/
private async enterFullscreen(id: string) {
try {
document.getElementById(id)!.requestFullscreen({
// Show browser navigation controls
navigationUI: "show",
});
} catch (err) {
console.error(err);
}
}
private onFullscreenChange = () => {
if (document.fullscreenElement) {
this.isFullscreen = true;
} else {
this.isFullscreen = false;
}
};
}
customElements.define("btrix-browser-profiles-new", BrowserProfilesNew);

View File

@ -69,7 +69,7 @@ export class CrawlTemplatesList extends LiteElement {
>
<a
href=${`/archives/${this.archiveId}/crawl-templates/new`}
class="col-span-1 bg-slate-50 border border-indigo-200 hover:border-indigo-400 text-primary text-center font-medium rounded px-6 py-4 transition-colors"
class="col-span-1 bg-slate-50 border border-indigo-200 hover:border-primary text-primary text-center font-medium rounded px-6 py-4 transition-colors"
@click=${this.navLink}
role="button"
>
@ -81,7 +81,7 @@ export class CrawlTemplatesList extends LiteElement {
class="inline-block align-middle mr-2 ${this.crawlTemplates.length
? "text-sm"
: "font-medium"}"
>${msg("Create New Crawl Template")}</span
>${msg("New Crawl Template")}</span
>
</a>
</div>

View File

@ -156,8 +156,8 @@ export class CrawlTemplatesNew extends LiteElement {
</a>
</nav>
<h2 class="text-xl font-bold mb-3">${msg("New Crawl Template")}</h2>
<p>
<h2 class="text-xl font-medium mb-3">${msg("New Crawl Template")}</h2>
<p class="text-neutral-500 text-sm">
${msg(
"Configure a new crawl template. You can choose to run a crawl immediately upon saving this template."
)}
@ -225,9 +225,7 @@ export class CrawlTemplatesNew extends LiteElement {
<sl-input
name="name"
label=${msg("Name")}
help-text=${msg(
"Required. Name your template to easily identify it later."
)}
help-text=${msg("Name your template to easily identify it later.")}
placeholder=${msg("Example (example.com) Weekly Crawl", {
desc: "Example crawl template name",
})}

View File

@ -15,6 +15,7 @@ import "./crawl-detail";
import "./crawls-list";
import "./browser-profiles-detail";
import "./browser-profiles-list";
import "./browser-profiles-new";
export type ArchiveTab =
| "crawls"
@ -45,6 +46,9 @@ export class Archive extends LiteElement {
@property({ type: String })
browserProfileId?: string;
@property({ type: String })
browserId?: string;
@property({ type: String })
crawlId?: string;
@ -270,9 +274,18 @@ export class Archive extends LiteElement {
></btrix-browser-profiles-detail>`;
}
if (this.browserId) {
return html`<btrix-browser-profiles-new
.authState=${this.authState!}
.archiveId=${this.archiveId!}
.browserId=${this.browserId}
></btrix-browser-profiles-new>`;
}
return html`<btrix-browser-profiles-list
.authState=${this.authState!}
.archiveId=${this.archiveId!}
?showCreateDialog=${this.isNewResourceTab}
></btrix-browser-profiles-list>`;
}

View File

@ -16,6 +16,7 @@ export const ROUTES = {
archiveAddMember: "/archives/:id/:tab/add-member",
archiveCrawl: "/archives/:id/:tab/crawl/:crawlId",
browserProfile: "/archives/:id/:tab/profile/:browserProfileId",
browser: "/archives/:id/:tab/profile/browser/:browserId",
crawlTemplate: "/archives/:id/:tab/config/:crawlConfigId",
crawlTemplateEdit: "/archives/:id/:tab/config/:crawlConfigId?edit",
users: "/users",