diff --git a/frontend/src/components/ui/code/index.ts b/frontend/src/components/ui/code/index.ts index 081e07b665..9af0351ac5 100644 --- a/frontend/src/components/ui/code/index.ts +++ b/frontend/src/components/ui/code/index.ts @@ -71,7 +71,7 @@ export class Code extends TailwindElement { language: Language = Language.XML; @property({ type: Boolean }) - wrap = true; + noWrap = false; async connectedCallback() { const languageFn = (await langaugeFiles[this.language]).default; @@ -94,7 +94,7 @@ export class Code extends TailwindElement { part="base" class=${clsx( tw`font-monospace m-0 text-neutral-600`, - this.wrap ? tw`whitespace-pre-wrap` : tw`whitespace-nowrap`, + this.noWrap ? tw`whitespace-nowrap` : tw`whitespace-pre-wrap`, )} >${staticHtml`${unsafeStatic(htmlStr)}`}`; } diff --git a/frontend/src/components/ui/desc-list.ts b/frontend/src/components/ui/desc-list.ts index a21ab6d8be..03d0ac58c0 100644 --- a/frontend/src/components/ui/desc-list.ts +++ b/frontend/src/components/ui/desc-list.ts @@ -127,6 +127,7 @@ export class DescList extends LitElement { vertical: !this.horizontal, horizontal: this.horizontal, })} + part="base" > `; diff --git a/frontend/src/components/ui/dialog.ts b/frontend/src/components/ui/dialog.ts index 5bd89544b4..d17cfffa29 100644 --- a/frontend/src/components/ui/dialog.ts +++ b/frontend/src/components/ui/dialog.ts @@ -10,6 +10,10 @@ import { * with custom CSS * * Usage: see https://shoelace.style/components/dialog + * + * @attr label + * @attr open + * @attr noHeader */ @customElement("btrix-dialog") export class Dialog extends SlDialog { @@ -27,8 +31,7 @@ export class Dialog extends SlDialog { } .dialog__header { - background-color: var(--sl-color-neutral-50); - border-bottom: 1px solid var(--sl-color-neutral-100); + border-bottom: 1px solid var(--sl-panel-border-color); } .dialog__title { @@ -50,7 +53,7 @@ export class Dialog extends SlDialog { .dialog__footer { padding-top: var(--sl-spacing-small); padding-bottom: var(--sl-spacing-small); - border-top: 1px solid var(--sl-color-neutral-100); + border-top: 1px solid var(--sl-panel-border-color); } `, ]; diff --git a/frontend/src/components/ui/table/table-header-cell.ts b/frontend/src/components/ui/table/table-header-cell.ts index d522af2340..0c14ae7687 100644 --- a/frontend/src/components/ui/table/table-header-cell.ts +++ b/frontend/src/components/ui/table/table-header-cell.ts @@ -16,6 +16,9 @@ export class TableHeaderCell extends TableCell { @property({ type: String, reflect: true, noAccessor: true }) role = "columnheader"; + @property({ type: String, reflect: true, noAccessor: true }) + scope?: "row" | "col"; + @property({ type: String, reflect: true }) ariaSort: SortValues = "none"; diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts index 4fc3108b82..3a1dd916ac 100644 --- a/frontend/src/components/ui/url-input.ts +++ b/frontend/src/components/ui/url-input.ts @@ -42,6 +42,11 @@ export class UrlInput extends SlInput { this.addEventListener("sl-change", this.onChange); } + setCustomValidity(message: string): void { + super.setCustomValidity(message); + if (!this.hideHelpText) this.helpText = message; + } + disconnectedCallback(): void { super.disconnectedCallback(); @@ -61,7 +66,6 @@ export class UrlInput extends SlInput { if (value && !validURL(value)) { const text = msg("Please enter a valid URL."); - if (!this.hideHelpText) this.helpText = text; this.setCustomValidity(text); } else if ( value && diff --git a/frontend/src/controllers/localize.ts b/frontend/src/controllers/localize.ts index 0690580e56..328d780a14 100644 --- a/frontend/src/controllers/localize.ts +++ b/frontend/src/controllers/localize.ts @@ -37,9 +37,10 @@ export class LocalizeController extends SlLocalizeController { year: "numeric", month: "long", day: "numeric", - hour: "2-digit", - minute: "2-digit", + hour: "numeric", + minute: "numeric", timeZoneName: "short", + weekday: "long", })} hoist placement="bottom" diff --git a/frontend/src/events/btrix-user-guide-show.ts b/frontend/src/events/btrix-user-guide-show.ts new file mode 100644 index 0000000000..36d98a9712 --- /dev/null +++ b/frontend/src/events/btrix-user-guide-show.ts @@ -0,0 +1,7 @@ +export type BtrixUserGuideShowEvent = CustomEvent<{ path?: string }>; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-user-guide-show": BtrixUserGuideShowEvent; + } +} diff --git a/frontend/src/features/archived-items/crawl-logs.ts b/frontend/src/features/archived-items/crawl-logs.ts index 4fa366ad58..f9d13479e6 100644 --- a/frontend/src/features/archived-items/crawl-logs.ts +++ b/frontend/src/features/archived-items/crawl-logs.ts @@ -222,7 +222,10 @@ export class CrawlLogs extends BtrixElement { `, () => this.filter - ? emptyMessage({ message: emptyMessageFor[this.filter] }) + ? emptyMessage({ + classNames: tw`border-y`, + message: emptyMessageFor[this.filter], + }) : nothing, )} `; diff --git a/frontend/src/features/browser-profiles/index.ts b/frontend/src/features/browser-profiles/index.ts index 7e843968d9..ab2f570127 100644 --- a/frontend/src/features/browser-profiles/index.ts +++ b/frontend/src/features/browser-profiles/index.ts @@ -1,3 +1,6 @@ import("./new-browser-profile-dialog"); +import("./profile-browser-dialog"); import("./profile-browser"); +import("./profile-metadata-dialog"); import("./select-browser-profile"); +import("./start-browser-dialog"); diff --git a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts index 5ba95ecb83..d6769933be 100644 --- a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts +++ b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts @@ -1,4 +1,4 @@ -import { localized, msg, str } from "@lit/localize"; +import { localized, msg } from "@lit/localize"; import { type SlInput } from "@shoelace-style/shoelace"; import { html, nothing, type PropertyValues } from "lit"; import { @@ -9,7 +9,7 @@ import { state, } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import queryString from "query-string"; +import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; @@ -24,6 +24,9 @@ import { @customElement("btrix-new-browser-profile-dialog") @localized() export class NewBrowserProfileDialog extends BtrixElement { + @property({ type: String }) + defaultUrl?: string; + @property({ type: String }) defaultProxyId?: string; @@ -40,7 +43,13 @@ export class NewBrowserProfileDialog extends BtrixElement { open = false; @state() - private isSubmitting = false; + browserOpen = false; + + @state() + private name?: string; + + @state() + private url?: string; @state() private crawlerChannel: CrawlerChannel["id"] = CrawlerChannelImage.Default; @@ -54,6 +63,17 @@ export class NewBrowserProfileDialog extends BtrixElement { @queryAsync("#browserProfileForm") private readonly form!: Promise; + connectedCallback(): void { + super.connectedCallback(); + + if (this.org?.crawlingDefaults) { + if (!this.defaultProxyId) + this.defaultProxyId = this.org.crawlingDefaults.proxyId; + if (!this.defaultCrawlerChannel) + this.defaultCrawlerChannel = this.org.crawlingDefaults.crawlerChannel; + } + } + protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("defaultProxyId") && this.defaultProxyId) { this.proxyId = this.proxyId || this.defaultProxyId; @@ -71,78 +91,109 @@ export class NewBrowserProfileDialog extends BtrixElement { } render() { - return html` { - const nameInput = (await this.form).querySelector( - 'sl-input[name="url"]', - ); - if (nameInput) { - e.preventDefault(); - nameInput.focus(); - } - }} - > -
{ + const nameInput = (await this.form).querySelector( + "btrix-url-input", + ); + if (nameInput) { + e.preventDefault(); + nameInput.focus(); + } + }} > - - - - ${this.crawlerChannels && this.crawlerChannels.length > 1 - ? html`
- - (this.crawlerChannel = e.detail.value!)} - > -
` - : nothing} - ${this.proxyServers?.length - ? html` -
- - (this.proxyId = e.detail.value)} - > -
- ` - : nothing} - - - -
- { - // Using reset method instead of type="reset" fixes - // incorrect getRootNode in Chrome - (await this.form).reset(); - }} - >${msg("Cancel")} - this.dialog?.submit()} - >${msg("Start Browsing")} -
-
`; + + + + ${this.crawlerChannels && this.crawlerChannels.length > 1 + ? html`
+ + (this.crawlerChannel = e.detail.value!)} + > +
` + : nothing} + ${this.proxyServers?.length + ? html` +
+ + (this.proxyId = e.detail.value)} + > +
+ ` + : nothing} + + + + + + +
+ { + // Using reset method instead of type="reset" fixes + // incorrect getRootNode in Chrome + (await this.form).reset(); + }} + >${msg("Cancel")} + this.dialog?.submit()} + > + ${msg("Start Browser")} + +
+ + + ${when( + this.url, + (url) => + html` {}} + @sl-after-hide=${() => {}} + > + `, + )} + `; } private async hideDialog() { @@ -155,71 +206,19 @@ export class NewBrowserProfileDialog extends BtrixElement { private async onSubmit(event: SubmitEvent) { event.preventDefault(); - this.isSubmitting = true; - - const formData = new FormData(event.target as HTMLFormElement); - let url = formData.get("url") as string; - - try { - url = url.trim(); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = `https://${url}`; - } - const data = await this.createBrowser({ - url: url, - crawlerChannel: this.crawlerChannel, - proxyId: this.proxyId, - }); - - this.notify.toast({ - message: msg("Starting up browser for new profile..."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-update-status", - }); - await this.hideDialog(); - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: msg("My Profile"), - crawlerChannel: this.crawlerChannel, - proxyId: this.proxyId, - })}`, - ); - } catch (e) { - this.notify.toast({ - message: msg("Sorry, couldn't create browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-update-status", - }); + + const form = event.target as HTMLFormElement; + + if (!form.checkValidity()) { + return; } - this.isSubmitting = false; - } - private async createBrowser({ - url, - crawlerChannel, - proxyId, - }: { - url: string; - crawlerChannel: string; - proxyId: string | null; - }) { - const params = { - url, - crawlerChannel, - proxyId, - }; - - return this.api.fetch<{ browserid: string }>( - `/orgs/${this.orgId}/profiles/browser`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); + const formData = new FormData(form); + this.name = formData.get("profile-name") as string; + this.url = formData.get("profile-url") as string; + + await this.updateComplete; + + this.browserOpen = true; } } diff --git a/frontend/src/features/browser-profiles/profile-browser-dialog.ts b/frontend/src/features/browser-profiles/profile-browser-dialog.ts new file mode 100644 index 0000000000..36b94e96b9 --- /dev/null +++ b/frontend/src/features/browser-profiles/profile-browser-dialog.ts @@ -0,0 +1,410 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import clsx from "clsx"; +import { html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { badges } from "./templates/badges"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import { + bgClass, + type BrowserConnectionChange, + type ProfileBrowser, +} from "@/features/browser-profiles/profile-browser"; +import type { + CreateBrowserOptions, + ProfileUpdatedEvent, +} from "@/features/browser-profiles/types"; +import { OrgTab } from "@/routes"; +import type { Profile } from "@/types/crawler"; +import { isApiError } from "@/utils/api"; +import { tw } from "@/utils/tailwind"; + +/** + * @fires btrix-updated + */ +@customElement("btrix-profile-browser-dialog") +@localized() +export class ProfileBrowserDialog extends BtrixElement { + @property({ type: Object }) + profile?: Profile; + + @property({ type: Object }) + config?: CreateBrowserOptions & { name?: string }; + + @property({ type: Boolean }) + duplicating = false; + + @property({ type: Boolean }) + open = false; + + @state() + private isBrowserLoaded = false; + + @query("btrix-dialog") + private readonly dialog?: Dialog | null; + + @query("btrix-profile-browser") + private readonly profileBrowser?: ProfileBrowser | null; + + #savedBrowserId?: string; + + private readonly browserIdTask = new Task(this, { + task: async ([open, config, profile], { signal }) => { + if (!open || !config) return null; + + const browserId = this.browserIdTask.value; + if (browserId && browserId !== this.#savedBrowserId) { + // Delete previously created and unused browser + void this.deleteBrowser(browserId, signal); + } + + const { browserid } = await this.createBrowser( + { profileId: profile?.id, ...config }, + signal, + ); + + return browserid; + }, + args: () => [this.open, this.config, this.profile] as const, + }); + + disconnectedCallback(): void { + const browserId = this.browserIdTask.value; + + if (browserId && browserId !== this.#savedBrowserId) { + void this.deleteBrowser(browserId); + } + + super.disconnectedCallback(); + } + + private readonly saveProfileTask = new Task(this, { + autoRun: false, + task: async ([browserId, config], { signal }) => { + if (!browserId || !config) return; + + try { + const data = await this.saveProfile( + { + browserId, + name: + config.name || + this.profile?.name || + new URL(config.url).origin.slice(0, 50), + }, + signal, + ); + + this.#savedBrowserId = browserId; + + this.dispatchEvent( + new CustomEvent("btrix-updated"), + ); + + return data; + } catch (err) { + let message = msg("Sorry, couldn't save browser profile at this time."); + + if (isApiError(err) && err.statusCode === 403) { + if (err.details === "storage_quota_reached") { + message = msg( + "Your org does not have enough storage to save this browser profile.", + ); + } else { + message = msg( + "You do not have permission to update browser profiles.", + ); + } + } + + throw message; + } + }, + args: () => [this.browserIdTask.value, this.config] as const, + }); + + render() { + const isCrawler = this.appState.isCrawler; + const creatingNew = this.duplicating || !this.profile; + const saving = this.saveProfileTask.status === TaskStatus.PENDING; + + return html` { + // Hide viewport scrollbar when full screen dialog is open + document.documentElement.classList.add(tw`overflow-hidden`); + }} + @sl-hide=${() => { + document.documentElement.classList.remove(tw`overflow-hidden`); + }} + @sl-after-hide=${() => this.closeBrowser()} + > +
+
+
+ { + this.saveProfileTask.abort(); + + if (this.browserIdTask.value) { + void this.deleteBrowser(this.browserIdTask.value); + } + + void this.dialog?.hide(); + }} + > + +
+
+

+ ${[this.config?.name || this.profile?.name, this.config?.url] + .filter((v) => v) + .join(" — ")} +

+ ${when( + (this.profile || this.config) && { + ...this.profile, + ...this.config, + }, + badges, + )} +
+
+
+
+ + void this.profileBrowser?.enterFullscreen()} + > + + + this.profileBrowser?.toggleOrigins()} + > + +
+ + ${when( + isCrawler, + () => html` + +
+ void this.submit()} + > + ${creatingNew ? msg("Create Profile") : msg("Save Profile")} + +
+
+ `, + )} +
+
+ +
+ ${this.browserIdTask.render({ + complete: (browserId) => + browserId + ? html`` + : nothing, + })} +
+
`; + } + + private readonly closeBrowser = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserLoad = () => { + this.isBrowserLoaded = true; + }; + + private readonly onBrowserReload = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserError = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserConnectionChange = ( + e: CustomEvent, + ) => { + this.isBrowserLoaded = e.detail.connected; + }; + + private async submit() { + this.notify.toast({ + message: msg("Saving profile..."), + variant: "primary", + icon: "info-circle", + id: "browser-profile-save-status", + duration: Infinity, + }); + + try { + const dialog = this.dialog; + + await this.saveProfileTask.run(); + + if (this.saveProfileTask.value?.id) { + void dialog?.hide(); + this.navigate.to( + `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}/profile/${this.saveProfileTask.value.id}`, + ); + } else { + await dialog?.hide(); + } + + this.notify.toast({ + message: msg("Successfully saved browser profile."), + variant: "success", + icon: "check2-circle", + id: "browser-profile-save-status", + }); + } catch (err) { + if (typeof err === "string") { + this.notify.toast({ + message: err, + variant: "danger", + icon: "exclamation-octagon", + id: "browser-profile-save-status", + }); + } else { + console.debug(err); + } + } + } + + private async saveProfile( + params: { browserId: string; name: string }, + signal: AbortSignal, + ) { + const profileId = !this.duplicating && this.profile?.id; + const payload = { + browserid: params.browserId, + name: params.name, + }; + + let data: { id?: string; updated?: boolean; detail?: string } | undefined; + let retriesLeft = 300; + + while (retriesLeft > 0) { + if (profileId) { + data = await this.api.fetch<{ + updated?: boolean; + detail?: string; + }>(`/orgs/${this.orgId}/profiles/${profileId}`, { + method: "PATCH", + body: JSON.stringify(payload), + signal, + }); + + if (data.updated !== undefined) { + break; + } + } else { + data = await this.api.fetch<{ id?: string; detail?: string }>( + `/orgs/${this.orgId}/profiles`, + { + method: "POST", + body: JSON.stringify(payload), + signal, + }, + ); + } + + if (data.id) { + break; + } + if (data.detail === "waiting_for_browser") { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } else { + throw new Error("unknown response"); + } + + retriesLeft -= 1; + } + + if (!retriesLeft) { + throw new Error("too many retries waiting for browser"); + } + + if (!data) { + throw new Error("unknown response"); + } + + return data; + } + + private async createBrowser( + params: CreateBrowserOptions, + signal?: AbortSignal, + ) { + return this.api.fetch<{ browserid: string }>( + `/orgs/${this.orgId}/profiles/browser`, + { + method: "POST", + body: JSON.stringify(params), + signal, + }, + ); + } + + private async deleteBrowser(browserId: string, signal?: AbortSignal) { + try { + const data = await this.api.fetch( + `/orgs/${this.orgId}/profiles/browser/${browserId}`, + { + method: "DELETE", + signal, + }, + ); + + return data; + } catch (err) { + if (isApiError(err) && err.statusCode === 404) { + // Safe to ignore, since unloaded browser will have already been deleted + } else { + console.debug(err); + } + } + } +} diff --git a/frontend/src/features/browser-profiles/profile-browser.ts b/frontend/src/features/browser-profiles/profile-browser.ts index 8248820a0e..4f88f643a5 100644 --- a/frontend/src/features/browser-profiles/profile-browser.ts +++ b/frontend/src/features/browser-profiles/profile-browser.ts @@ -1,10 +1,18 @@ import { localized, msg, str } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { cache } from "lit/directives/cache.js"; import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; +import { emptyMessage } from "@/layouts/emptyMessage"; import { isApiError, type APIError } from "@/utils/api"; +import { tw } from "@/utils/tailwind"; + +// Matches background of embedded browser +// TODO See if this can be configurable via API +export const bgClass = tw`bg-[#282828]`; const POLL_INTERVAL_SECONDS = 2; const hiddenClassList = ["translate-x-2/3", "opacity-0", "pointer-events-none"]; @@ -21,6 +29,10 @@ export type BrowserConnectionChange = { connected: boolean; }; +const isPolling = (value: unknown): value is number => { + return typeof value === "number"; +}; + /** * View embedded profile browser * @@ -37,6 +49,9 @@ export type BrowserConnectionChange = { * @fires btrix-browser-error * @fires btrix-browser-reload * @fires btrix-browser-connection-change + * @cssPart base + * @cssPart browser + * @cssPart iframe */ @customElement("btrix-profile-browser") @localized() @@ -47,20 +62,8 @@ export class ProfileBrowser extends BtrixElement { @property({ type: String }) initialNavigateUrl?: string; - @property({ type: Array }) - origins?: string[]; - - @state() - private iframeSrc?: string; - - @state() - private isIframeLoaded = false; - - @state() - private browserNotAvailable = false; - - @state() - private browserDisconnected = false; + @property({ type: Boolean }) + hideControls = false; @state() private isFullscreen = false; @@ -68,54 +71,177 @@ export class ProfileBrowser extends BtrixElement { @state() private showOriginSidebar = false; - @state() - private newOrigins?: string[] = []; - @query("#interactiveBrowser") private readonly interactiveBrowser?: HTMLElement; @query("#profileBrowserSidebar") private readonly sidebar?: HTMLElement; - @query("#iframeWrapper") - private readonly iframeWrapper?: HTMLElement; - @query("iframe") private readonly iframe?: HTMLIFrameElement; - private pollTimerId?: number; + /** + * Get temporary browser. + * If the browser is not available, the task also handles polling. + */ + private readonly browserTask = new Task(this, { + task: async ([browserId], { signal }) => { + if (!browserId) { + console.debug("missing browserId"); + return; + } + + if (isPolling(this.browserTask.value)) { + window.clearTimeout(this.browserTask.value); + } + + const poll = () => + window.setTimeout(() => { + if (!signal.aborted) { + void this.browserTask.run(); + } + }, POLL_INTERVAL_SECONDS * 1000); + + let data: BrowserResponseData; + + try { + data = await this.getBrowser(browserId, signal); + } catch (err) { + void this.onBrowserError(); + throw err; + } + + // Check whether temporary browser is up + if (data.detail === "waiting_for_browser") { + return poll(); + } + + if (data.url) { + // check that the browser is actually available + // if not, continue waiting + // (will not work with local frontend due to CORS) + try { + const resp = await fetch(data.url, { method: "HEAD" }); + if (!resp.ok) { + return poll(); + } + } catch (err) { + console.debug(err); + } + } + + if (this.initialNavigateUrl) { + await this.navigateBrowser({ url: this.initialNavigateUrl }, signal); + } + + window.addEventListener("beforeunload", this.onBeforeUnload); + + this.dispatchEvent( + new CustomEvent("btrix-browser-load", { + detail: data.url, + }), + ); + + return { + id: browserId, + ...data, + }; + }, + args: () => [this.browserId] as const, + }); + + /** + * Get updated origins list in temporary browser + */ + private readonly originsTask = new Task(this, { + task: async ([browser], { signal }) => { + window.clearTimeout(this.pingTask.value); + + if (!browser || isPolling(browser)) return; + + try { + const data = await this.pingBrowser(browser.id, signal); + + return data.origins; + } catch (err) { + if (isApiError(err) && err.details === "no_such_browser") { + void this.onBrowserError(); + } else { + void this.onBrowserDisconnected(); + } + + throw err; + } + }, + args: () => [this.browserTask.value] as const, + }); + + /** + * Keep temporary browser alive by polling for origins list + */ + private readonly pingTask = new Task(this, { + task: async ([origins], { signal }) => { + window.clearTimeout(this.pingTask.value); + + if (!origins) { + return; + } + + return window.setTimeout(() => { + if (!signal.aborted) { + void this.originsTask.run(); + } + }, POLL_INTERVAL_SECONDS * 1000); + }, + args: () => [this.originsTask.value] as const, + }); connectedCallback() { super.connectedCallback(); document.addEventListener("fullscreenchange", this.onFullscreenChange); - window.addEventListener("beforeunload", this.onBeforeUnload); } disconnectedCallback() { - window.clearTimeout(this.pollTimerId); + if (isPolling(this.browserTask.value)) + window.clearTimeout(this.browserTask.value); + + window.clearTimeout(this.pingTask.value); + document.removeEventListener("fullscreenchange", this.onFullscreenChange); window.removeEventListener("beforeunload", this.onBeforeUnload); super.disconnectedCallback(); } - private onBeforeUnload(e: BeforeUnloadEvent) { + private readonly onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); - } + }; + + private readonly onBrowserError = async () => { + window.removeEventListener("beforeunload", this.onBeforeUnload); + + await this.updateComplete; + this.dispatchEvent( + new CustomEvent("btrix-browser-error"), + ); + }; + + private readonly onBrowserDisconnected = async () => { + window.removeEventListener("beforeunload", this.onBeforeUnload); + + await this.updateComplete; + this.dispatchEvent( + new CustomEvent( + "btrix-browser-connection-change", + { + detail: { connected: false }, + }, + ), + ); + }; willUpdate(changedProperties: PropertyValues & Map) { - if (changedProperties.has("browserId")) { - if (this.browserId) { - window.clearTimeout(this.pollTimerId); - void 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 @@ -124,29 +250,8 @@ export class ProfileBrowser extends BtrixElement { } } - updated(changedProperties: PropertyValues & Map) { - if (changedProperties.has("browserDisconnected")) { - this.dispatchEvent( - new CustomEvent( - "btrix-browser-connection-change", - { - detail: { - connected: !this.browserDisconnected, - }, - }, - ), - ); - } - if (changedProperties.has("browserNotAvailable")) { - if (this.browserNotAvailable) { - window.removeEventListener("beforeunload", this.onBeforeUnload); - } else { - window.addEventListener("beforeunload", this.onBeforeUnload); - } - this.dispatchEvent( - new CustomEvent("btrix-browser-error"), - ); - } + public toggleOrigins() { + this.showOriginSidebar = !this.showOriginSidebar; } private animateSidebar() { @@ -159,17 +264,88 @@ export class ProfileBrowser extends BtrixElement { } render() { + const loadingMsgChangeDelay = 10000; + const browserLoading = () => + cache( + html`
+
+ +

+ ${msg("Loading interactive browser...")} +

+
+ +

+ ${msg("Browser is still warming up...")} +

+
+
+ + +
`, + ); + return html` -
+
${this.renderControlBar()}
- ${this.renderBrowser()} + ${this.browserTask.render({ + initial: browserLoading, + pending: browserLoading, + error: () => html` +
+ +

+ ${msg( + `Interactive browser session timed out due to inactivity.`, + )} +

+
+ + + ${msg("Load New Browser")} + +
+
+
+ `, + complete: (result) => + !result || isPolling(result) + ? browserLoading() + : this.renderBrowser(result), + })}
- ${this.renderOrigins()} ${this.renderNewOrigins()} + ${when( + this.originsTask.value, + (origins) => html` + ${this.renderOrigins(origins)} + ${this.renderNewOrigins( + origins.filter( + (url: string) => + !origins.includes(url) && + !origins.includes(url.replace(/\/$/, "")), + ), + )} + `, + () => + this.browserTask.status === TaskStatus.PENDING + ? emptyMessage({ + message: msg( + "Sites will be shown here once the browser is done loading.", + ), + }) + : emptyMessage({ + message: msg("No sites configured yet."), + }), + )}
@@ -187,14 +385,14 @@ export class ProfileBrowser extends BtrixElement { `; } - private renderControlBar() { + private readonly renderControlBar = () => { if (this.isFullscreen) { return html`
${this.renderSidebarButton()} - + void document.exitFullscreen()} @@ -204,22 +402,28 @@ export class ProfileBrowser extends BtrixElement { `; } + if (this.hideControls) return; + return html`
-
+
${msg("Interactive Browser")} - - - + +
${this.renderSidebarButton()} - + void this.enterFullscreen()} @@ -228,210 +432,141 @@ export class ProfileBrowser extends BtrixElement {
`; - } - - private renderBrowser() { - if (this.browserNotAvailable) { - return html` -
- -

- ${msg(`Interactive browser session timed out due to inactivity.`)} -

-
- - - ${msg("Load New Browser")} - -
-
-
- `; - } - - if (this.iframeSrc) { - return html`
- - ${when( - this.browserDisconnected, - () => html` -
- -

- ${msg( - "Connection to interactive browser lost. Waiting to reconnect...", - )} -

-
-
- `, - )} -
`; - } - - if (this.browserId && !this.isIframeLoaded) { - return html` -
- -
- `; - } + }; - return ""; - } + private readonly renderBrowser = (browser: BrowserResponseData) => { + return html`
+ ${when( + browser.url, + (url) => html` + + `, + )} + ${this.originsTask.render({ + error: () => html` +
+ +

+ ${msg( + "Connection to interactive browser lost. Waiting to reconnect...", + )} +

+
+
+ `, + })} +
`; + }; private renderSidebarButton() { return html` - + (this.showOriginSidebar = !this.showOriginSidebar)} + @click=${() => this.toggleOrigins()} aria-pressed=${this.showOriginSidebar} > `; } - private renderOrigins() { + private renderOrigins(origins: string[]) { return html` -

- ${msg("Visited Sites")} - -

+
+
+

${msg("Saved Sites")}

+ +
+ (this.showOriginSidebar = false)} + > + +
    - ${this.origins?.map((url) => this.renderOriginItem(url))} + ${origins.map((url) => this.renderOriginItem(url))}
`; } - private renderNewOrigins() { - if (!this.newOrigins?.length) return; + private renderNewOrigins(origins: string[]) { + if (!origins.length) return; return html` -

- ${msg("New Sites")} - -

+
+
+

${msg("New Sites")}

+ +
+
    - ${this.newOrigins.map((url) => this.renderOriginItem(url))} + ${origins.map((url) => this.renderOriginItem(url))}
`; } private renderOriginItem(url: string) { + const iframeSrc = + !isPolling(this.browserTask.value) && this.browserTask.value?.url; + return html`
  • (this.iframeSrc ? this.navigateBrowser({ url }) : {})} + @click=${() => (iframeSrc ? this.navigateBrowser({ url }) : {})} >
    ${url}
    - ${this.iframeSrc - ? html`` + ${iframeSrc + ? html`` : ""}
  • `; } private onClickReload() { - this.dispatchEvent(new CustomEvent("btrix-browser-reload")); - } - - /** - * Fetch browser profile and update internal state - */ - private async fetchBrowser(): Promise { - await this.updateComplete; - - this.iframeSrc = undefined; - this.isIframeLoaded = false; + this.showOriginSidebar = false; - await this.checkBrowserStatus(); - } - - /** - * Check whether temporary browser is up - **/ - private async checkBrowserStatus() { - let result: BrowserResponseData; - try { - result = await this.getBrowser(); - this.browserNotAvailable = false; - } catch (e) { - this.browserNotAvailable = true; - return; - } - - if (result.detail === "waiting_for_browser") { - this.pollTimerId = window.setTimeout( - () => void this.checkBrowserStatus(), - POLL_INTERVAL_SECONDS * 1000, - ); - - return; - } else if (result.url) { - // check that the browser is actually available - // if not, continue waiting - // (will not work with local frontend due to CORS) - try { - const resp = await fetch(result.url, { method: "HEAD" }); - if (!resp.ok) { - this.pollTimerId = window.setTimeout( - () => void this.checkBrowserStatus(), - POLL_INTERVAL_SECONDS * 1000, - ); - return; - } - } catch (e) { - // ignore - } - - if (this.initialNavigateUrl) { - await this.navigateBrowser({ url: this.initialNavigateUrl }); - } - - this.iframeSrc = result.url; - - await this.updateComplete; - - this.dispatchEvent( - new CustomEvent("btrix-browser-load", { - detail: result.url, - }), - ); - - void this.pingBrowser(); - } else { - console.debug("Unknown checkBrowserStatus state"); - } + this.dispatchEvent(new CustomEvent("btrix-browser-reload")); } - private async getBrowser() { + private async getBrowser(browserId: string, signal?: AbortSignal) { const data = await this.api.fetch( - `/orgs/${this.orgId}/profiles/browser/${this.browserId}`, + `/orgs/${this.orgId}/profiles/browser/${browserId}`, + { signal }, ); return data; @@ -440,14 +575,16 @@ export class ProfileBrowser extends BtrixElement { /** * Navigate to URL in temporary browser **/ - private async navigateBrowser({ url }: { url: string }) { - if (!this.iframeSrc) return; - + private async navigateBrowser( + { url }: { url: string }, + signal?: AbortSignal, + ) { const data = this.api.fetch( `/orgs/${this.orgId}/profiles/browser/${this.browserId}/navigate`, { method: "POST", body: JSON.stringify({ url }), + signal, }, ); @@ -457,46 +594,20 @@ export class ProfileBrowser extends BtrixElement { /** * Ping temporary browser to keep it alive **/ - private async pingBrowser() { - if (!this.iframeSrc) return; - - try { - const data = await this.api.fetch<{ origins?: string[] }>( - `/orgs/${this.orgId}/profiles/browser/${this.browserId}/ping`, - { - method: "POST", - }, - ); - - if (!this.origins) { - this.origins = data.origins; - } else { - this.newOrigins = data.origins?.filter( - (url: string) => !this.origins?.includes(url), - ); - } - - this.browserDisconnected = false; - } catch (e) { - if (isApiError(e) && e.details === "no_such_browser") { - this.browserNotAvailable = true; - } else { - this.browserDisconnected = true; - } - - await this.updateComplete; - } - - this.pollTimerId = window.setTimeout( - () => void this.pingBrowser(), - POLL_INTERVAL_SECONDS * 1000, + private async pingBrowser(browserId: string, signal?: AbortSignal) { + return this.api.fetch<{ origins?: string[] }>( + `/orgs/${this.orgId}/profiles/browser/${browserId}/ping`, + { + method: "POST", + signal, + }, ); } /** * Enter fullscreen mode */ - private async enterFullscreen() { + public async enterFullscreen() { try { await this.interactiveBrowser?.requestFullscreen({ // Hide browser navigation controls @@ -507,16 +618,17 @@ export class ProfileBrowser extends BtrixElement { } } - private onIframeLoad() { - this.isIframeLoaded = true; + private async onIframeLoad(url: string) { try { this.iframe?.contentWindow?.localStorage.setItem("uiTheme", '"default"'); - } catch (e) { - /* empty */ + } catch (err) { + console.debug(err); } + + await this.updateComplete; this.dispatchEvent( new CustomEvent("btrix-browser-load", { - detail: this.iframeSrc, + detail: url, }), ); } diff --git a/frontend/src/features/browser-profiles/profile-metadata-dialog.ts b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts new file mode 100644 index 0000000000..b331d1ff4a --- /dev/null +++ b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts @@ -0,0 +1,200 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import type { SlInput, SlTextarea } from "@shoelace-style/shoelace"; +import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import type { ProfileUpdatedEvent } from "@/features/browser-profiles/types"; +import type { Profile } from "@/types/crawler"; +import { isApiError } from "@/utils/api"; +import { maxLengthValidator } from "@/utils/form"; + +/** + * @fires btrix-updated + */ +@customElement("btrix-profile-metadata-dialog") +@localized() +export class ProfileMetadataDialog extends BtrixElement { + @property({ type: Object }) + profile?: Profile; + + @property({ type: Boolean }) + open = false; + + @property({ type: String }) + autofocusOn?: "name" | "description"; + + @state() + private isDialogVisible = false; + + @query("btrix-dialog") + private readonly dialog?: Dialog | null; + + @query("form") + private readonly form?: HTMLFormElement | null; + + @query(`sl-input[name="name"]`) + private readonly nameInput?: SlInput | null; + + @query(`sl-textarea[name="description"]`) + private readonly descriptionInput?: SlTextarea | null; + + private readonly validateNameMax = maxLengthValidator(50); + private readonly validateDescriptionMax = maxLengthValidator(500); + + private readonly submitTask = new Task(this, { + autoRun: false, + task: async ([profile], { signal }) => { + if (!this.form || !profile) { + console.debug("no form or profile", this.form, profile); + return; + } + + const params = serialize(this.form) as { + name: string; + description: string; + }; + + try { + await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/profiles/${profile.id}`, + { + method: "PATCH", + body: JSON.stringify(params), + signal, + }, + ); + + this.dispatchEvent( + new CustomEvent("btrix-updated", { + detail: params, + }), + ); + + this.notify.toast({ + message: msg("Updated browser profile metadata."), + variant: "success", + icon: "check2-circle", + id: "browser-profile-save-status", + }); + } catch (e) { + let message = msg("Sorry, couldn't save browser profile at this time."); + + if (isApiError(e) && e.statusCode === 403) { + if (e.details === "storage_quota_reached") { + message = msg( + "Your org does not have enough storage to save this browser profile.", + ); + } else { + message = msg( + "You do not have permission to edit browser profiles.", + ); + } + } + + this.notify.toast({ + message: message, + variant: "danger", + icon: "exclamation-octagon", + id: "browser-profile-save-status", + }); + } + }, + args: () => [this.profile] as const, + }); + + render() { + return html` (this.isDialogVisible = true)} + @sl-initial-focus=${async () => { + await this.updateComplete; + + switch (this.autofocusOn) { + case "name": + this.nameInput?.focus(); + break; + case "description": + this.descriptionInput?.focus(); + break; + default: + break; + } + }} + @sl-after-hide=${() => (this.isDialogVisible = false)} + >${this.isDialogVisible ? this.renderForm() : ""} + `; + } + + private renderForm() { + if (!this.profile) return; + + const submitting = this.submitTask.status === TaskStatus.PENDING; + + return html` +
    { + e.preventDefault(); + + if (this.form?.checkValidity()) { + void this.submitTask.run(); + } + }} + @reset=${() => void this.dialog?.hide()} + > + + + + + ${ + // + undefined + } +
    +
    + ${msg("Cancel")} + ${msg("Save")} +
    + `; + } +} diff --git a/frontend/src/features/browser-profiles/start-browser-dialog.ts b/frontend/src/features/browser-profiles/start-browser-dialog.ts new file mode 100644 index 0000000000..2e57714c69 --- /dev/null +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -0,0 +1,134 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import type { SlButton } from "@shoelace-style/shoelace"; +import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { html } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import type { UrlInput } from "@/components/ui/url-input"; +import { + orgCrawlerChannelsContext, + type OrgCrawlerChannelsContext, +} from "@/context/org-crawler-channels"; +import type { Profile } from "@/types/crawler"; + +type StartBrowserEventDetail = { + url?: string; + crawlerChannel?: Profile["crawlerChannel"]; +}; + +export type BtrixStartBrowserEvent = CustomEvent; + +/** + * Start browser with specified profile and additional configuration. + * + * @fires btrix-start-browser + */ +@customElement("btrix-start-browser-dialog") +@localized() +export class StartBrowserDialog extends BtrixElement { + @consume({ context: orgCrawlerChannelsContext, subscribe: true }) + private readonly orgCrawlerChannels?: OrgCrawlerChannelsContext; + + @property({ type: Object }) + profile?: Profile; + + @property({ type: String }) + startUrl?: string; + + @property({ type: Boolean }) + open = false; + + @query("btrix-dialog") + private readonly dialog?: Dialog | null; + + @query("form") + private readonly form?: HTMLFormElement | null; + + @query("#submit-button") + private readonly submitButton?: SlButton | null; + + render() { + return html` { + await this.updateComplete; + + if (this.startUrl) { + this.submitButton?.focus(); + } else { + this.dialog?.querySelector("btrix-url-input")?.focus(); + } + }} + @sl-after-hide=${async () => { + if (this.form) { + this.form.reset(); + + const input = this.form.querySelector("btrix-url-input"); + if (input) { + input.value = ""; + input.setCustomValidity(""); + } + } + + void this.dialog?.hide(); + }} + > +
    { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + + if (!form.checkValidity()) return; + + const values = serialize(form); + const url = values["starting-url"] as string; + const crawlerChannel = values["crawlerChannel"] as string | undefined; + + this.dispatchEvent( + new CustomEvent("btrix-start-browser", { + detail: { url, crawlerChannel }, + }), + ); + }} + > + + + ${when( + this.orgCrawlerChannels && this.orgCrawlerChannels.length > 1, + () => html` +
    + + +
    + `, + )} + +
    + void this.dialog?.hide()} + >${msg("Cancel")} + this.dialog?.submit()} + > + ${msg("Start Browser")} + +
    +
    `; + } +} diff --git a/frontend/src/features/browser-profiles/templates/badges.ts b/frontend/src/features/browser-profiles/templates/badges.ts new file mode 100644 index 0000000000..303b2810dd --- /dev/null +++ b/frontend/src/features/browser-profiles/templates/badges.ts @@ -0,0 +1,53 @@ +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { when } from "lit/directives/when.js"; +import capitalize from "lodash/fp/capitalize"; + +import { CrawlerChannelImage, type Profile } from "@/types/crawler"; + +export const usageBadge = (inUse: boolean) => + html` + + + ${inUse ? msg("In Use") : msg("Not In Use")} + + `; + +export const badges = ( + profile: Partial>, +) => { + return html`
    + ${profile.inUse === undefined ? nothing : usageBadge(profile.inUse)} + ${when( + profile.crawlerChannel, + (channel) => + html` + + + ${capitalize(channel)} + + `, + )} + ${when( + profile.proxyId, + (proxy) => + html` + + + ${proxy} + + `, + )} +
    `; +}; + +export const badgesSkeleton = () => + html``; diff --git a/frontend/src/features/browser-profiles/types.ts b/frontend/src/features/browser-profiles/types.ts new file mode 100644 index 0000000000..57c15dd399 --- /dev/null +++ b/frontend/src/features/browser-profiles/types.ts @@ -0,0 +1,12 @@ +import type { Profile } from "@/types/crawler"; + +export type ProfileUpdatedEvent = CustomEvent< + Partial> +>; + +export type CreateBrowserOptions = { + url: string; + profileId?: string; + crawlerChannel?: string; + proxyId?: string; +}; diff --git a/frontend/src/features/collections/collection-edit-dialog.ts b/frontend/src/features/collections/collection-edit-dialog.ts index fed4f33913..8884f31b08 100644 --- a/frontend/src/features/collections/collection-edit-dialog.ts +++ b/frontend/src/features/collections/collection-edit-dialog.ts @@ -249,7 +249,7 @@ export class CollectionEdit extends BtrixElement { })} ${this.renderTab({ panel: "sharing", - icon: "globe2", + icon: "globe", string: msg("Sharing"), })} diff --git a/frontend/src/features/collections/select-collection-access.ts b/frontend/src/features/collections/select-collection-access.ts index 2e8c9d5df9..2065ac0c4a 100644 --- a/frontend/src/features/collections/select-collection-access.ts +++ b/frontend/src/features/collections/select-collection-access.ts @@ -27,7 +27,7 @@ export class SelectCollectionAccess extends BtrixElement { }, [CollectionAccess.Public]: { label: msg("Public"), - icon: "globe2", + icon: "globe", detail: msg("Anyone can view from the org public collections gallery"), }, }; diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 88da94c53d..c8ef39aec2 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -71,6 +71,7 @@ import { type IntersectEvent, } from "@/controllers/observable"; import type { BtrixChangeEvent } from "@/events/btrix-change"; +import type { BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; import { type SelectBrowserProfileChangeEvent } from "@/features/browser-profiles/select-browser-profile"; import type { CollectionsChangeEvent } from "@/features/collections/collections-add"; import type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table"; @@ -79,11 +80,10 @@ import type { ExclusionChangeEvent, QueueExclusionTable, } from "@/features/crawl-workflows/queue-exclusion-table"; -import type { UserGuideEventMap } from "@/index"; import { infoCol, inputCol } from "@/layouts/columns"; import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav"; import { panel } from "@/layouts/panel"; -import { WorkflowTab } from "@/routes"; +import { OrgTab, WorkflowTab } from "@/routes"; import { infoTextFor } from "@/strings/crawl-workflows/infoText"; import { labelFor } from "@/strings/crawl-workflows/labels"; import scopeTypeLabels from "@/strings/crawl-workflows/scopeType"; @@ -486,7 +486,19 @@ export class WorkflowEditor extends BtrixElement { (changedProperties.get("progressState") as ProgressState | undefined) ?.activeTab ) { - window.location.hash = this.progressState.activeTab; + const prevActiveTab = ( + changedProperties.get("progressState") as ProgressState | undefined + )?.activeTab; + + if (this.progressState.activeTab !== prevActiveTab) { + if (prevActiveTab) { + window.location.hash = this.progressState.activeTab; + } else { + const url = new URL(window.location.href); + url.hash = this.progressState.activeTab; + window.location.replace(url); + } + } } const prevFormState = changedProperties.get("formState") as | FormState @@ -2430,7 +2442,7 @@ https://archiveweb.page/images/${"logo.svg"}`} e.preventDefault(); this.dispatchEvent( - new CustomEvent( + new CustomEvent( "btrix-user-guide-show", { detail: { path }, @@ -2821,7 +2833,7 @@ https://archiveweb.page/images/${"logo.svg"}`} if (this.appState.userGuideOpen) { this.dispatchEvent( - new CustomEvent( + new CustomEvent( "btrix-user-guide-show", { detail: { @@ -3125,10 +3137,16 @@ https://archiveweb.page/images/${"logo.svg"}`} } private async onReset() { + const fromBrowserProfile = + !this.initialSeeds && + !this.initialSeedFile && + this.initialWorkflow?.profileid; + this.navigate.to( - `${this.navigate.orgBasePath}/workflows${this.configId ? `/${this.configId}/${WorkflowTab.Settings}` : ""}`, + fromBrowserProfile + ? `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}/profile/${fromBrowserProfile}` + : `${this.navigate.orgBasePath}/${OrgTab.Workflows}${this.configId ? `/${this.configId}/${WorkflowTab.Settings}` : ""}`, ); - // this.initializeEditor(); } private validateUrlList( diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index 00a9327e81..85ae28e311 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -32,6 +32,21 @@ import { humanizeSchedule } from "@/utils/cron"; import { srOnly, truncate } from "@/utils/css"; import { pluralOf } from "@/utils/pluralize"; +export type WorkflowColumnName = + | "name" + | "latest-crawl" + | "total-crawls" + | "modified" + | "actions"; + +const columnWidths = { + name: "minmax(18rem, 1fr)", + "latest-crawl": "minmax(15rem, 18rem)", + "total-crawls": "minmax(6rem, 9rem)", + modified: "minmax(12rem, 15rem)", + actions: "3rem", +} as const satisfies Record; + // postcss-lit-disable-next-line const mediumBreakpointCss = css`30rem`; // postcss-lit-disable-next-line @@ -41,6 +56,13 @@ const rowCss = css` .row { display: grid; grid-template-columns: 1fr; + position: relative; + } + + .action { + position: absolute; + top: 0; + right: 0; } @media only screen and (min-width: ${mediumBreakpointCss}) { @@ -50,7 +72,12 @@ const rowCss = css` } @media only screen and (min-width: ${largeBreakpointCss}) { .row { - grid-template-columns: 1fr 17rem 10rem 11rem 3rem; + grid-template-columns: var(--btrix-workflow-list-columns); + white-space: nowrap; + } + + .action { + position: relative; } } @@ -231,33 +258,27 @@ export class WorkflowListItem extends BtrixElement { @property({ type: Object }) workflow?: ListWorkflow; + /** + * Limit columns displayed + * @TODO Convert to btrix-data-grid to make columns configurable + */ + @property({ type: Array }) + columns?: WorkflowColumnName[]; + @query(".row") row!: HTMLElement; @query("btrix-overflow-dropdown") dropdownMenu!: OverflowDropdown; - render() { - return html`
    { - if (e.target === this.dropdownMenu) { - return; - } - e.preventDefault(); - await this.updateComplete; - const failedStates = ["failed", "failed_not_logged_in"]; - const href = `/orgs/${this.orgSlugState}/workflows/${this.workflow?.id}/${failedStates.includes(this.workflow?.lastCrawlState || "") ? WorkflowTab.Logs : WorkflowTab.LatestCrawl}`; - this.navigate.to(href); - }} - > -
    + private readonly columnTemplate = { + name: () => + html`
    ${when(this.workflow?.shareable, ShareableNotice)} ${this.safeRender(this.renderName)}
    -
    +
    ${this.safeRender((workflow) => { if (workflow.schedule) { return humanizeSchedule(workflow.schedule, { @@ -270,9 +291,11 @@ export class WorkflowListItem extends BtrixElement { return msg("---"); })}
    -
    -
    ${this.safeRender(this.renderLatestCrawl)}
    -
    +
    `, + "latest-crawl": () => + html`
    ${this.safeRender(this.renderLatestCrawl)}
    `, + "total-crawls": () => + html`
    ${this.safeRender((workflow) => { if ( @@ -316,9 +339,11 @@ export class WorkflowListItem extends BtrixElement { `${this.localize.number(workflow.crawlCount, { notation: "compact" })} ${pluralOf("crawls", workflow.crawlCount)}`, )}
    -
    -
    ${this.safeRender(this.renderModifiedBy)}
    -
    +
    `, + modified: () => + html`
    ${this.safeRender(this.renderModifiedBy)}
    `, + actions: () => + html`
    -
    +
    `, + } satisfies Record TemplateResult>; + + render() { + return html`
    { + if (e.target === this.dropdownMenu) { + return; + } + e.preventDefault(); + await this.updateComplete; + const failedStates = ["failed", "failed_not_logged_in"]; + const href = `/orgs/${this.orgSlugState}/workflows/${this.workflow?.id}/${failedStates.includes(this.workflow?.lastCrawlState || "") ? WorkflowTab.Logs : WorkflowTab.LatestCrawl}`; + this.navigate.to(href); + }} + > + ${this.columns + ? this.columns.map((col) => this.columnTemplate[col]()) + : Object.values(this.columnTemplate).map((render) => render())}
    `; } @@ -551,18 +596,41 @@ export class WorkflowList extends LitElement { `, ]; + /** + * Limit columns displayed + * * @TODO Convert to btrix-data-grid to make columns configurable + */ + @property({ type: Array, noAccessor: true }) + columns?: WorkflowColumnName[]; + @queryAssignedElements({ selector: "btrix-workflow-list-item" }) - listItems!: HTMLElement[]; + listItems!: WorkflowListItem[]; + + static ColumnTemplate = { + name: html`
    ${msg(html`Name & Schedule`)}
    `, + "latest-crawl": html`
    ${msg("Latest Crawl")}
    `, + "total-crawls": html`
    ${msg("Total Size")}
    `, + modified: html`
    ${msg("Last Modified")}
    `, + actions: html`
    + ${msg("Actions")} +
    `, + } satisfies Record; + + connectedCallback(): void { + this.style.setProperty( + "--btrix-workflow-list-columns", + this.columns?.map((col) => columnWidths[col]).join(" ") || + Object.values(columnWidths).join(" "), + ); + + super.connectedCallback(); + } render() { - return html`
    -
    ${msg(html`Name & Schedule`)}
    -
    ${msg("Latest Crawl")}
    -
    ${msg("Total Size")}
    -
    ${msg("Last Modified")}
    -
    - ${msg("Actions")} -
    + return html`
    + ${this.columns + ? this.columns.map((col) => WorkflowList.ColumnTemplate[col]) + : Object.values(WorkflowList.ColumnTemplate)}
    @@ -574,6 +642,12 @@ export class WorkflowList extends LitElement { if (!el.attributes.getNamedItem("role")) { el.setAttribute("role", "listitem"); } + + if (this.columns) { + if (!el["columns"]) { + el["columns"] = this.columns; + } + } }); } } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 0a3674c32d..684123676f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -21,6 +21,7 @@ import "./pages"; import { docsUrlContext } from "./context/docs-url"; import { viewStateContext } from "./context/view-state"; +import type { BtrixUserGuideShowEvent } from "./events/btrix-user-guide-show"; import { OrgTab, RouteNamespace } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; import { pageView, type AnalyticsTrackProps } from "./utils/analytics"; @@ -63,10 +64,6 @@ export type APIUser = { orgs: UserOrg[]; }; -export interface UserGuideEventMap { - "btrix-user-guide-show": CustomEvent<{ path?: string }>; -} - @customElement("browsertrix-app") @localized() export class App extends BtrixElement { @@ -169,7 +166,7 @@ export class App extends BtrixElement { private attachUserGuideListeners() { this.addEventListener( "btrix-user-guide-show", - (e: UserGuideEventMap["btrix-user-guide-show"]) => { + (e: BtrixUserGuideShowEvent) => { e.stopPropagation(); void this.showUserGuide(e.detail.path); }, diff --git a/frontend/src/layouts/emptyMessage.ts b/frontend/src/layouts/emptyMessage.ts index ddaad6673f..06f33a6756 100644 --- a/frontend/src/layouts/emptyMessage.ts +++ b/frontend/src/layouts/emptyMessage.ts @@ -7,16 +7,18 @@ export function emptyMessage({ message, detail, actions, + classNames, }: { message: TemplateResult | string; detail?: TemplateResult | string; actions?: TemplateResult; + classNames?: typeof tw | string; }) { return html` -
    +

    @@ -39,10 +39,12 @@ export function page( >

    ${header.breadcrumbs ? html` ${pageNav(header.breadcrumbs)} ` : nothing} - ${pageHeader(header)} -
    ${render()}
    + ${header.title ? pageHeader(header) : nothing} + ${header.title + ? html`
    ${render()}
    ` + : render()}
    `; } diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index 2446b4a0e3..3b6f7478e2 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -128,7 +128,7 @@ export function pageHeader({ classNames, )} > -
    +
    ${prefix}${pageTitle(title)}${suffix}
    diff --git a/frontend/src/layouts/panel.ts b/frontend/src/layouts/panel.ts index 6bfe56fb2a..e295647d1f 100644 --- a/frontend/src/layouts/panel.ts +++ b/frontend/src/layouts/panel.ts @@ -1,8 +1,11 @@ +import clsx from "clsx"; import { html, type TemplateResult } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { pageHeading } from "./page"; +import { tw } from "@/utils/tailwind"; + export function panelHeader({ heading, actions, @@ -20,8 +23,18 @@ export function panelHeader({ `; } -export function panelBody({ content }: { content: TemplateResult }) { - return html`
    ${content}
    `; +export function panelBody({ + content, + classNames, +}: { + content: TemplateResult; + classNames?: typeof tw | string; +}) { + return html`
    + ${content} +
    `; } /** @@ -39,6 +52,8 @@ export function panel({ className?: string; } & Parameters[0]) { return html`
    - ${panelHeader({ heading, actions })} ${body} + ${panelHeader({ heading, actions })} + + ${body}
    `; } diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts deleted file mode 100644 index d57abb7a23..0000000000 --- a/frontend/src/pages/org/browser-profiles-detail.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { localized, msg, str } from "@lit/localize"; -import { html, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { when } from "lit/directives/when.js"; -import capitalize from "lodash/fp/capitalize"; -import queryString from "query-string"; - -import type { Profile } from "./types"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { Dialog } from "@/components/ui/dialog"; -import { ClipboardController } from "@/controllers/clipboard"; -import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser"; -import { pageNav } from "@/layouts/pageHeader"; -import { isApiError } from "@/utils/api"; -import { maxLengthValidator } from "@/utils/form"; -import { isArchivingDisabled } from "@/utils/orgs"; -import { richText } from "@/utils/rich-text"; - -const DESCRIPTION_MAXLENGTH = 500; - -/** - * Usage: - * ```ts - * - * ``` - */ -@customElement("btrix-browser-profiles-detail") -@localized() -export class BrowserProfilesDetail extends BtrixElement { - @property({ type: String }) - profileId!: string; - - @property({ type: Boolean }) - isCrawler = false; - - @state() - private profile?: Profile; - - @state() - private isBrowserLoading = false; - - @state() - private isBrowserLoaded = false; - - @state() - private isSubmittingBrowserChange = false; - - @state() - private isSubmittingProfileChange = false; - - @state() - private browserId?: string; - - @state() - private isEditDialogOpen = false; - - @state() - private isEditDialogContentVisible = false; - - @query("#profileBrowserContainer") - private readonly profileBrowserContainer?: HTMLElement | null; - - @query("#discardChangesDialog") - private readonly discardChangesDialog?: Dialog | null; - - private readonly validateNameMax = maxLengthValidator(50); - private readonly validateDescriptionMax = maxLengthValidator(500); - - private updatedProfileTimer?: number; - - disconnectedCallback() { - window.clearTimeout(this.updatedProfileTimer); - if (this.browserId) { - void this.deleteBrowser(this.browserId); - } - super.disconnectedCallback(); - } - - firstUpdated() { - void this.fetchProfile(); - } - - render() { - const isBackedUp = - this.profile?.resource?.replicas && - this.profile.resource.replicas.length > 0; - const none = html`${msg("None")}`; - - return html`
    ${this.renderBreadcrumbs()}
    - -
    -

    - ${this.profile?.name - ? html`${this.profile.name}` - : html``} -

    -
    - ${this.profile - ? this.renderMenu() - : html``} -
    -
    - -
    - - - ${this.profile - ? this.profile.crawlerChannel - ? capitalize(this.profile.crawlerChannel) - : none - : nothing} - - - ${this.profile - ? html` - - ` - : nothing} - - - ${this.profile - ? html` ` - : nothing} - - ${ - // NOTE older profiles may not have "modified/created by" data - this.profile?.modifiedByName || this.profile?.createdByName - ? html` - - ${this.profile.modifiedByName || this.profile.createdByName} - - ` - : nothing - } - -
    - -
    -
    -
    -

    - ${msg("Browser Profile")} -

    - - - - - ${this.profile?.inUse - ? html` - - - ${msg("In Use")} - - - - ` - : html` - - `} -
    - - ${when(this.isCrawler, () => - this.browserId || this.isBrowserLoading - ? html` -
    -
    - - (this.isBrowserLoaded = true)} - @btrix-browser-error=${this.onBrowserError} - @btrix-browser-reload=${this.startBrowserPreview} - @btrix-browser-connection-change=${this - .onBrowserConnectionChange} - > -
    - ${this.renderBrowserProfileControls()} -
    -
    -
    - ` - : html`
    -

    - ${msg( - "View or edit the current browser profile configuration.", - )} -

    - - - ${msg("Configure Browser Profile")} - -
    `, - )} -
    - ${when( - !(this.browserId || this.isBrowserLoading), - this.renderVisitedSites, - )} -
    - -
    -
    -

    - ${msg("Description")} -

    - ${when( - this.isCrawler, - () => html` - (this.isEditDialogOpen = true)} - label=${msg("Edit description")} - > - `, - )} -
    - -
    ${this.profile - ? this.profile.description - ? richText(this.profile.description) - : html` -
    -  ${msg("No description added.")} -
    - ` - : nothing}
    -
    - - - ${msg( - "Are you sure you want to discard changes to this browser profile?", - )} -
    - void this.discardChangesDialog?.hide()} - > - ${msg("No, Continue Editing")} - - { - void this.cancelEditBrowser(); - void this.discardChangesDialog?.hide(); - }} - >${msg("Yes, Discard Changes")} - -
    -
    - - (this.isEditDialogOpen = false)} - @sl-show=${() => (this.isEditDialogContentVisible = true)} - @sl-after-hide=${() => (this.isEditDialogContentVisible = false)} - > - ${this.isEditDialogContentVisible ? this.renderEditProfile() : nothing} - `; - } - - private renderBreadcrumbs() { - const breadcrumbs = [ - { - href: `${this.navigate.orgBasePath}/browser-profiles`, - content: msg("Browser Profiles"), - }, - { - content: this.profile?.name, - }, - ]; - - return pageNav(breadcrumbs); - } - - private readonly renderVisitedSites = () => { - return html` -
    -
    -

    - ${msg("Visited Sites")} -

    -
    -
    -
      - ${this.profile?.origins.map((origin) => html`
    • ${origin}
    • `)} -
    -
    -
    - `; - }; - - private renderBrowserProfileControls() { - return html` -
    - - ${msg("Cancel")} - -
    - - ${msg("Save Browser Profile")} - -
    -
    - `; - } - - private renderMenu() { - const archivingDisabled = isArchivingDisabled(this.org); - - return html` - - - ${msg("Actions")} - - - (this.isEditDialogOpen = true)}> - - ${msg("Edit Metadata")} - - - - ${msg("Configure Browser Profile")} - - void this.duplicateProfile()} - > - - ${msg("Duplicate Profile")} - - - ClipboardController.copyToClipboard(this.profileId)} - > - - ${msg("Copy Profile ID")} - - - void this.deleteProfile()} - > - - ${msg("Delete Profile")} - - - - `; - } - - private renderEditProfile() { - if (!this.profile) return; - - return html` -
    -
    - -
    - -
    - -
    - -
    - (this.isEditDialogOpen = false)} - >${msg("Cancel")} - ${msg("Save Changes")} -
    -
    - `; - } - - private async onBrowserError() { - this.isBrowserLoaded = false; - } - - private async onBrowserConnectionChange( - e: CustomEvent, - ) { - this.isBrowserLoaded = e.detail.connected; - } - - private async onCancel() { - if (this.isBrowserLoaded) { - void this.discardChangesDialog?.show(); - } else { - void this.cancelEditBrowser(); - } - } - - private async startBrowserPreview() { - if (!this.profile) return; - - this.isBrowserLoading = true; - - const url = this.profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.browserId = data.browserid; - this.isBrowserLoading = false; - - await this.updateComplete; - - this.profileBrowserContainer?.scrollIntoView({ behavior: "smooth" }); - } catch (e) { - this.isBrowserLoading = false; - - this.notify.toast({ - message: msg("Sorry, couldn't preview browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async cancelEditBrowser() { - const prevBrowserId = this.browserId; - - this.isBrowserLoaded = false; - this.browserId = undefined; - - if (prevBrowserId) { - try { - await this.deleteBrowser(prevBrowserId); - } catch (e) { - // TODO Investigate DELETE is returning 404 - console.debug(e); - } - } - } - - private async duplicateProfile() { - if (!this.profile) return; - - this.isBrowserLoading = true; - - const url = this.profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.notify.toast({ - message: msg("Starting up browser with current profile..."), - variant: "success", - icon: "check2-circle", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: this.profile.name, - description: this.profile.description.slice(0, DESCRIPTION_MAXLENGTH), - profileId: this.profile.id, - crawlerChannel: this.profile.crawlerChannel, - proxyId: this.profile.proxyId, - })}`, - ); - } catch (e) { - this.isBrowserLoading = false; - - this.notify.toast({ - message: msg("Sorry, couldn't create browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async deleteProfile() { - const profileName = this.profile!.name; - - try { - await this.api.fetch( - `/orgs/${this.orgId}/profiles/${this.profile!.id}`, - { - method: "DELETE", - }, - ); - - this.navigate.to(`${this.navigate.orgBasePath}/browser-profiles`); - - this.notify.toast({ - message: msg(html`Deleted ${profileName}.`), - variant: "success", - icon: "check2-circle", - }); - } catch (e) { - let message = msg( - html`Sorry, couldn't delete browser profile at this time.`, - ); - - if (isApiError(e)) { - if (e.message === "profile_in_use") { - message = msg( - html`Could not delete ${profileName}, currently in - use. Please remove browser profile from all crawl workflows to - continue.`, - ); - } - } - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async createBrowser({ url }: { url: string }) { - const params = { - url, - profileId: this.profile!.id, - }; - - return this.api.fetch<{ browserid: string }>( - `/orgs/${this.orgId}/profiles/browser`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); - } - - private async deleteBrowser(id: string) { - return this.api.fetch(`/orgs/${this.orgId}/profiles/browser/${id}`, { - method: "DELETE", - }); - } - - /** - * Fetch browser profile and update internal state - */ - private async fetchProfile(): Promise { - try { - const data = await this.getProfile(); - - this.profile = data; - } catch (e) { - this.notify.toast({ - message: msg("Sorry, couldn't retrieve browser profiles at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async getProfile() { - const data = await this.api.fetch( - `/orgs/${this.orgId}/profiles/${this.profileId}`, - ); - - return data; - } - - private async saveBrowser() { - if (!this.browserId) return; - - this.isSubmittingBrowserChange = true; - - const params = { - name: this.profile!.name, - browserid: this.browserId, - }; - - try { - let retriesLeft = 300; - - while (retriesLeft > 0) { - const data = await this.api.fetch<{ - updated?: boolean; - detail?: string; - }>(`/orgs/${this.orgId}/profiles/${this.profileId}`, { - method: "PATCH", - body: JSON.stringify(params), - }); - if (data.updated !== undefined) { - break; - } - if (data.detail === "waiting_for_browser") { - await new Promise((resolve) => { - this.updatedProfileTimer = window.setTimeout(resolve, 2000); - }); - } else { - throw new Error("unknown response"); - } - - retriesLeft -= 1; - } - - if (!retriesLeft) { - throw new Error("too many retries waiting for browser"); - } - - this.notify.toast({ - message: msg("Successfully saved browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.browserId = undefined; - } catch (e) { - console.debug(e); - - this.notify.toast({ - message: msg("Sorry, couldn't save browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-save-status", - }); - } - - this.isSubmittingBrowserChange = false; - } - - private async onSubmitEdit(e: SubmitEvent) { - e.preventDefault(); - - const formEl = e.target as HTMLFormElement; - if (!(await this.checkFormValidity(formEl))) return; - - const formData = new FormData(formEl); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - - const params = { - name, - description, - }; - - this.isSubmittingProfileChange = true; - - try { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/profiles/${this.profileId}`, - { - method: "PATCH", - body: JSON.stringify(params), - }, - ); - - if (data.updated) { - this.notify.toast({ - message: msg("Successfully saved browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.profile = { - ...this.profile, - ...params, - } as Profile; - this.isEditDialogOpen = false; - } else { - throw data; - } - } catch (e) { - let message = msg("Sorry, couldn't save browser profile at this time."); - - if (isApiError(e) && e.statusCode === 403) { - if (e.details === "storage_quota_reached") { - message = msg( - "Your org does not have enough storage to save this browser profile.", - ); - } else { - message = msg("You do not have permission to edit browser profiles."); - } - } - - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-save-status", - }); - } - - this.isSubmittingProfileChange = false; - } - - async checkFormValidity(formEl: HTMLFormElement) { - await this.updateComplete; - return !formEl.querySelector("[data-invalid]"); - } -} diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index cf9d4b5629..f31682852b 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -32,7 +32,7 @@ import { isArchivingDisabled } from "@/utils/orgs"; const SORT_DIRECTIONS = ["asc", "desc"] as const; type SortDirection = (typeof SORT_DIRECTIONS)[number]; -type SortField = "name" | "url" | "modified"; +type SortField = "name" | "url" | "modified" | "created"; type SortBy = { field: SortField; direction: SortDirection; @@ -51,7 +51,11 @@ const sortableFields: Record< defaultDirection: "asc", }, modified: { - label: msg("Last Modified"), + label: msg("Modified By User"), + defaultDirection: "desc", + }, + created: { + label: msg("Date Created"), defaultDirection: "desc", }, }; @@ -74,6 +78,12 @@ const columnsCss = [ @customElement("btrix-browser-profiles-list") @localized() export class BrowserProfilesList extends BtrixElement { + @state() + private selectedProfile?: Profile; + + @state() + private openDialog?: "duplicate"; + @state() private pagination: Required = { page: parsePage(new URLSearchParams(location.search).get("page")), @@ -227,9 +237,29 @@ export class BrowserProfilesList extends BtrixElement { : this.renderEmpty()} `, )} + ${when(this.selectedProfile, this.renderDuplicateDialog)} `; }; + private readonly renderDuplicateDialog = (profile: Profile) => { + return html` { + this.selectedProfile = undefined; + this.openDialog = undefined; + }} + > + `; + }; + private renderEmpty() { const message = msg("Your org doesn’t have any browser profiles yet."); @@ -377,6 +407,11 @@ export class BrowserProfilesList extends BtrixElement { }; private readonly renderItem = (data: Profile) => { + const modifiedByAnyDate = + [data.modifiedCrawlDate, data.modified, data.created].reduce( + (a, b) => (b && a && b > a ? b : a), + data.created, + ) || data.created; const startingUrl = data.origins[0]; const otherOrigins = data.origins.slice(1); @@ -417,9 +452,7 @@ export class BrowserProfilesList extends BtrixElement { : nothing} - ${this.localize.relativeDate(data.modified || data.created, { - capitalize: true, - })} + ${this.localize.relativeDate(modifiedByAnyDate)} ${this.renderActions(data)} @@ -464,35 +497,9 @@ export class BrowserProfilesList extends BtrixElement { } private async duplicateProfile(profile: Profile) { - const url = profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.notify.toast({ - message: msg("Starting up browser with selected profile..."), - variant: "success", - icon: "check2-circle", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: profile.name, - description: profile.description, - profileId: profile.id, - crawlerChannel: profile.crawlerChannel, - })}`, - ); - } catch (e) { - this.notify.toast({ - message: msg("Sorry, couldn't create browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } + this.selectedProfile = profile; + await this.updateComplete; + this.openDialog = "duplicate"; } private async deleteProfile(profile: Profile) { diff --git a/frontend/src/pages/org/browser-profiles-new.ts b/frontend/src/pages/org/browser-profiles-new.ts deleted file mode 100644 index 5ee7bc9bdc..0000000000 --- a/frontend/src/pages/org/browser-profiles-new.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { localized, msg, str } from "@lit/localize"; -import { html } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import queryString from "query-string"; - -import { CrawlerChannelImage } from "./types"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { Dialog } from "@/components/ui/dialog"; -import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser"; -import { pageNav, type Breadcrumb } from "@/layouts/pageHeader"; -import { isApiError } from "@/utils/api"; - -/** - * Usage: - * ```ts - * - * ``` - */ -@customElement("btrix-browser-profiles-new") -@localized() -export class BrowserProfilesNew extends BtrixElement { - @property({ type: String }) - browserId!: string; - - @property({ type: Object, attribute: false }) - browserParams: { - name: string; - url: string; - description?: string; - crawlerChannel?: string; - profileId?: string | null; - navigateUrl?: string; - proxyId: string | null; - } = { - name: "", - url: "", - proxyId: null, - }; - - @state() - private isSubmitting = false; - - @state() - private isDialogVisible = false; - - @state() - private isBrowserLoaded = false; - - @query("#discardDialog") - private readonly discardDialog?: Dialog | null; - - disconnectedCallback(): void { - void this.closeBrowser(); - super.disconnectedCallback(); - } - - render() { - return html` -
    ${this.renderBreadcrumbs()}
    - -
    -

    - ${msg("New Browser Profile")} -

    -
    - -

    - ${msg( - "Workflows that use this browser profile will behave as if they have logged into the same websites and have the same web cookies.", - )} -
    - ${msg(html` - It is highly recommended to create dedicated accounts to use when - crawling. For details, refer to - - ${msg("browser profile best practices")}. - `)} -

    - - ${this.browserParams.profileId - ? html` -
    - ${msg( - html`Extending ${this.browserParams.name}`, - )} -
    - ` - : ""} - -
    - (this.isBrowserLoaded = true)} - @btrix-browser-reload=${this.onBrowserReload} - @btrix-browser-error=${this.onBrowserError} - @btrix-browser-connection-change=${this.onBrowserConnectionChange} - > - -
    - ${this.renderBrowserProfileControls()} -
    -
    - - (this.isDialogVisible = false)} - > - ${this.renderForm()} - - - - ${msg("Are you sure you want to cancel creating a browser profile?")} -
    - void this.discardDialog?.hide()} - > - ${msg("No, Continue Browsing")} - - { - void this.discardDialog?.hide(); - void this.closeBrowser(); - }} - >${msg("Yes, Cancel")} - -
    -
    - `; - } - - private renderBreadcrumbs() { - const breadcrumbs: Breadcrumb[] = [ - { - href: `${this.navigate.orgBasePath}/browser-profiles`, - content: msg("Browser Profiles"), - }, - ]; - - if (this.browserParams.profileId) { - breadcrumbs.push( - { - href: `${this.navigate.orgBasePath}/browser-profiles/profile/${this.browserParams.profileId}`, - content: this.browserParams.name - ? this.browserParams.name - : msg("Browser Profile"), - }, - { - content: msg("Duplicate Profile"), - }, - ); - } - - return pageNav(breadcrumbs); - } - - private async onBrowserError() { - this.isBrowserLoaded = false; - } - - private async onBrowserConnectionChange( - e: CustomEvent, - ) { - this.isBrowserLoaded = e.detail.connected; - } - - private onCancel() { - if (!this.isBrowserLoaded) { - void this.closeBrowser(); - } else { - void this.discardDialog?.show(); - } - } - - private async closeBrowser() { - this.isBrowserLoaded = false; - - if (this.browserId) { - await this.deleteBrowser(this.browserId); - } - this.navigate.to(`${this.navigate.orgBasePath}/browser-profiles`); - } - - private renderBrowserProfileControls() { - return html` -
    - - ${msg("Cancel")} - -
    - (this.isDialogVisible = true)} - > - ${msg("Save New Profile...")} - -
    -
    - `; - } - - private renderForm() { - return html`
    -
    - - - - -
    - (this.isDialogVisible = false)} - > - ${msg("Back")} - - - - ${msg("Save Profile")} - -
    -
    -
    `; - } - - private async onBrowserReload() { - const { url } = this.browserParams; - if (!url) { - console.debug("no start url"); - return; - } - - const crawlerChannel = - this.browserParams.crawlerChannel || CrawlerChannelImage.Default; - const proxyId = this.browserParams.proxyId; - const data = await this.createBrowser({ - url, - crawlerChannel, - proxyId, - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: this.browserParams.name || msg("My Profile"), - crawlerChannel, - proxyId, - })}`, - ); - } - - private async onSubmit(event: SubmitEvent) { - event.preventDefault(); - this.isSubmitting = true; - - const formData = new FormData(event.target as HTMLFormElement); - const params = { - browserid: this.browserId, - name: formData.get("name"), - description: formData.get("description"), - crawlerChannel: this.browserParams.crawlerChannel, - proxyId: this.browserParams.proxyId, - }; - - try { - let data; - let retriesLeft = 300; - - while (retriesLeft > 0) { - data = await this.api.fetch<{ id?: string; detail?: string }>( - `/orgs/${this.orgId}/profiles`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); - if (data.id) { - break; - } - if (data.detail === "waiting_for_browser") { - await new Promise((resolve) => setTimeout(resolve, 2000)); - } else { - throw new Error("unknown response"); - } - - retriesLeft -= 1; - } - - if (!retriesLeft) { - throw new Error("too many retries waiting for browser"); - } - - if (!data) { - throw new Error("unknown response"); - } - - this.notify.toast({ - message: msg("Successfully created browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/${data.id}`, - ); - } catch (e) { - console.debug(e); - - this.isSubmitting = false; - - let message = msg("Sorry, couldn't create browser profile at this time."); - - if (isApiError(e) && e.statusCode === 403) { - if (e.details === "storage_quota_reached") { - message = msg( - "Your org does not have enough storage to save this browser profile.", - ); - } else { - message = msg( - "You do not have permission to create browser profiles.", - ); - } - } - - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-save-status", - }); - } - } - private async createBrowser({ - url, - crawlerChannel, - proxyId, - }: { - url: string; - crawlerChannel: string; - proxyId: string | null; - }) { - const params = { - url, - crawlerChannel, - proxyId, - }; - - return this.api.fetch<{ browserid: string }>( - `/orgs/${this.orgId}/profiles/browser`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); - } - - private async deleteBrowser(id: string) { - try { - const data = await this.api.fetch( - `/orgs/${this.orgId}/profiles/browser/${id}`, - { - method: "DELETE", - }, - ); - - return data; - } catch (e) { - // TODO Investigate DELETE returning 404 - console.debug(e); - } - } -} diff --git a/frontend/src/pages/org/browser-profiles/index.ts b/frontend/src/pages/org/browser-profiles/index.ts new file mode 100644 index 0000000000..be1f854d48 --- /dev/null +++ b/frontend/src/pages/org/browser-profiles/index.ts @@ -0,0 +1 @@ +import(/* webpackChunkName: "org" */ "./profile"); diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts new file mode 100644 index 0000000000..3945384788 --- /dev/null +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -0,0 +1,848 @@ +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import type { SlMenuItem } from "@shoelace-style/shoelace"; +import { html, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; +import queryString from "query-string"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { + BtrixFilterChipChangeEvent, + FilterChip, +} from "@/components/ui/filter-chip"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; +import { ClipboardController } from "@/controllers/clipboard"; +import { CrawlStatus } from "@/features/archived-items/crawl-status"; +import type { BtrixStartBrowserEvent } from "@/features/browser-profiles/start-browser-dialog"; +import { + badges, + badgesSkeleton, +} from "@/features/browser-profiles/templates/badges"; +import type { WorkflowColumnName } from "@/features/crawl-workflows/workflow-list"; +import { emptyMessage } from "@/layouts/emptyMessage"; +import { labelWithIcon } from "@/layouts/labelWithIcon"; +import { page } from "@/layouts/page"; +import { panel, panelBody } from "@/layouts/panel"; +import { OrgTab, WorkflowTab } from "@/routes"; +import { noData, stringFor } from "@/strings/ui"; +import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; +import type { Profile, Workflow } from "@/types/crawler"; +import type { CrawlState } from "@/types/crawlState"; +import { SortDirection } from "@/types/utils"; +import { isApiError } from "@/utils/api"; +import { settingsForDuplicate } from "@/utils/crawl-workflows/settingsForDuplicate"; +import { isNotEqual } from "@/utils/is-not-equal"; +import { isArchivingDisabled } from "@/utils/orgs"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; +import type { SectionsEnum } from "@/utils/workflow"; + +const INITIAL_PAGE_SIZE = 5; +const WORKFLOW_PAGE_QUERY = "workflowsPage"; + +const workflowColumns = [ + "name", + "latest-crawl", + "actions", +] as const satisfies WorkflowColumnName[]; + +@customElement("btrix-browser-profiles-profile-page") +@localized() +export class BrowserProfilesProfilePage extends BtrixElement { + @property({ type: String }) + profileId = ""; + + @state({ hasChanged: isNotEqual }) + private workflowParams: Required & { + lastCrawlState?: CrawlState[]; + } = { + page: parsePage( + new URLSearchParams(location.search).get(WORKFLOW_PAGE_QUERY), + ), + pageSize: INITIAL_PAGE_SIZE, + }; + + @state() + private openDialog?: + | "metadata" + | "metadata-name" + | "metadata-description" + | "start-browser" + | "start-browser-add-site" + | "browser" + | "duplicate"; + + @state() + private browserLoadUrl?: string; + + @state() + private browserLoadCrawlerChannel?: Profile["crawlerChannel"]; + + private get profile() { + return this.profileTask.value; + } + + private readonly profileTask = new Task(this, { + task: async ([profileId], { signal }) => { + const profile = await this.getProfile(profileId, signal); + + return profile; + }, + args: () => [this.profileId] as const, + }); + + private readonly workflowsTask = new Task(this, { + task: async ([profileId, workflowParams], { signal }) => { + return this.getWorkflows({ ...workflowParams, profileId }, signal); + }, + args: () => [this.profileId, this.workflowParams] as const, + }); + + render() { + const header = { + breadcrumbs: [ + { + href: `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}`, + content: msg("Browser Profiles"), + }, + { + content: this.profile?.name, + }, + ], + title: this.profile?.name, + suffix: when( + this.profile && this.appState.isCrawler, + () => + html` + + `, + ), + secondary: when(this.profile, badges, badgesSkeleton), + actions: this.renderActions(), + } satisfies Parameters[0]; + + const duplicating = this.openDialog === "duplicate"; + + return html`${page(header, this.renderPage)} + + void this.profileTask.run()} + @sl-after-hide=${this.closeBrowser} + > + + + ${when( + this.profile, + (profile) => + html` (this.openDialog = undefined)} + @btrix-updated=${() => { + void this.profileTask.run(); + this.openDialog = undefined; + }} + > + `, + )} `; + } + + private renderActions() { + const archivingDisabled = isArchivingDisabled(this.org); + const isCrawler = this.appState.isCrawler; + const menuItemClick = (cb: () => void) => (e: MouseEvent) => { + if (e.defaultPrevented || (e.currentTarget as SlMenuItem).disabled) + return; + cb(); + }; + + return html` + + + ${msg("Actions")} + + + ${when( + isCrawler, + () => html` + (this.openDialog = "metadata")}> + + ${msg("Edit Metadata")} + + (this.openDialog = "start-browser"), + )} + > + + ${msg("Configure Profile")} + + void this.duplicateProfile())} + > + + ${msg("Duplicate Profile")} + + + + + ${msg("New Workflow with Profile")} + + + `, + )} + ClipboardController.copyToClipboard(this.profileId)} + > + + ${msg("Copy Profile ID")} + + ${when(isCrawler, () => { + const disabled = this.profile?.inUse; + return html` + + void this.deleteProfile())} + > + + ${msg("Delete Profile")} + + `; + })} + + + `; + } + + private readonly renderPage = () => { + return html` +
    +
    + ${this.renderProfile()} ${this.renderUsage()} +
    + +
    + ${this.renderOverview()} +
    +
    + `; + }; + + private renderProfile() { + const archivingDisabled = isArchivingDisabled(this.org); + const isCrawler = this.appState.isCrawler; + + return panel({ + heading: msg("Configured Sites"), + actions: isCrawler + ? html` + (this.openDialog = "start-browser")} + ?disabled=${archivingDisabled} + > + ` + : undefined, + body: html`${this.renderOrigins()} + ${when( + isCrawler, + () => html` + (this.openDialog = "start-browser-add-site")} + > + + ${msg("Add Site")} + `, + )} + ${when( + this.profileTask.value, + (profile) => html` + { + void this.openBrowser(e.detail); + }} + @sl-after-hide=${() => { + if (this.openDialog?.startsWith("start-browser")) { + this.openDialog = undefined; + } + }} + > + `, + )} `, + }); + } + + private renderOrigins() { + const originsSkeleton = () => + html`
    `; + + const origins = (profile: Profile) => + profile.origins.map( + (origin) => html` +
  • + + +
    + + + + + +
    +
  • + `, + ); + + return when( + this.profile, + (profile) => html` +
    +
      + ${origins(profile)} +
    +
    + `, + originsSkeleton, + ); + } + + private renderOverview() { + const none = html`${stringFor.none}`; + const profile = this.profile; + const modifiedByAnyDate = profile + ? [profile.modifiedCrawlDate, profile.modified, profile.created].reduce( + (a, b) => (b && a && b > a ? b : a), + profile.created, + ) + : null; + + return panel({ + heading: msg("Overview"), + actions: this.appState.isCrawler + ? html` + (this.openDialog = "metadata-description")} + > + ` + : undefined, + body: html` + + + ${this.renderDetail((profile) => + profile.description + ? html` + +
    ${profile.description}
    + ` + : none, + )} +
    + ${ + // + // ${this.renderDetail(() => html`${none}`)} + // + undefined + } +
    + + + + ${this.renderDetail((profile) => + this.localize.bytes(profile.resource?.size || 0), + )} + + + ${this.renderDetail( + (profile) => + `${this.localize.number(profile.origins.length)} ${pluralOf("domains", profile.origins.length)}`, + )} + + + ${this.renderDetail((profile) => + this.localize.relativeDate(modifiedByAnyDate || profile.created), + )} + + + ${this.renderDetail((profile) => { + let modifier: string | TemplateResult = + profile.createdByName || msg("User"); + + switch (modifiedByAnyDate) { + case profile.modifiedCrawlDate: + modifier = html` + ${msg("Workflow Crawl")} + `; + break; + case profile.modified: + modifier = profile.modifiedByName || modifier; + break; + default: + break; + } + + return modifier; + })} + + ${when( + this.profile, + (profile) => + modifiedByAnyDate === profile.modifiedCrawlDate + ? html` + ${profile.modifiedByName} + (${this.localize.relativeDate( + profile.modified || profile.created, + )}) + ` + : html` + ${this.localize.date(profile.created, { + dateStyle: "medium", + })} + `, + () => + html` + + + `, + )} + + + ${this.renderDetail((profile) => + profile.created + ? this.localize.date(profile.created, { + dateStyle: "medium", + }) + : noData, + )} + + + ${this.renderDetail((profile) => { + const isBackedUp = + profile.resource?.replicas && + profile.resource.replicas.length > 0; + return labelWithIcon({ + label: isBackedUp ? msg("Backed Up") : msg("Not Backed Up"), + icon: html``, + }); + })} + + + `, + }); + } + + private renderUsage() { + const workflowListSkeleton = () => + html``; + + return panel({ + heading: msg("Related Workflows"), + body: when( + this.profile, + (profile) => + profile.inUse + ? html` +
    + + + ${this.workflowsTask.value + ? `${this.localize.number(this.workflowsTask.value.total)} ${pluralOf("workflows", this.workflowsTask.value.total)}` + : html``} + + + ${profile.modifiedCrawlId && profile.modifiedCrawlDate + ? html` +
    + ${this.localize.relativeDate( + profile.modifiedCrawlDate, + )} + + + +
    + ` + : msg("Never")} +
    +
    +
    + + ${this.workflowsTask.render({ + initial: workflowListSkeleton, + pending: () => + this.workflowsTask.value + ? this.renderWorkflows(this.workflowsTask.value) + : workflowListSkeleton(), + complete: this.renderWorkflows, + })} + ` + : panelBody({ + content: emptyMessage({ + message: msg( + "This profile is not in use by any crawl workflows.", + ), + actions: html`
    + + + ${msg("Manage Workflows")} + + + + ${msg("New Workflow with Profile")} + +
    `, + }), + }), + workflowListSkeleton, + ), + }); + } + + private readonly renderWorkflows = ( + workflows: APIPaginatedList, + ) => { + const failedStates = [ + "failed", + "failed_not_logged_in", + ] satisfies CrawlState[]; + + return html` +
    +
    + + ${msg("Filter by:")} + + + + (failedStates as CrawlState[]).includes(state), + )} + @btrix-change=${(e: BtrixFilterChipChangeEvent) => { + const { checked } = e.target as FilterChip; + + this.workflowParams = { + ...this.workflowParams, + lastCrawlState: checked ? failedStates : undefined, + }; + }} + > + ${CrawlStatus.getContent({ state: "failed" }).label} + +
    +
    + + ${workflows.total + ? html` + + ${workflows.items.map( + (workflow) => + html` + + ${when( + this.appState.isCrawler, + () => html` + + + ${msg("Edit Workflow Settings")} + + + `, + )} + + + ${msg("Go to Workflow")} + + + `, + )} + + +
    + { + this.workflowParams = { + ...this.workflowParams, + page: e.detail.page, + }; + }} + > + +
    + ` + : emptyMessage({ + classNames: tw`border-y`, + message: msg("No matching workflows found."), + actions: html` + + (this.workflowParams = { + ...this.workflowParams, + lastCrawlState: undefined, + })} + > + + ${msg("Clear filters")} + `, + })} + `; + }; + + private readonly renderDetail = ( + render: (profile: Profile) => string | TemplateResult, + ) => + when( + this.profile, + render, + () => html``, + ); + + private async getFirstBrowserUrl() { + if (!this.profile) { + await this.profileTask.taskComplete; + } + + return this.profile?.origins[0]; + } + + private readonly newWorkflow = () => { + this.navigate.to( + `${this.navigate.orgBasePath}/${OrgTab.Workflows}/new#${"browserSettings" satisfies SectionsEnum}`, + settingsForDuplicate({ + workflow: { + profileid: this.profileId, + proxyId: this.profile?.proxyId, + crawlerChannel: this.profile?.crawlerChannel, + }, + }), + ); + + this.notify.toast({ + message: msg("Copied browser settings to new workflow."), + variant: "success", + icon: "check2-circle", + id: "workflow-copied-status", + duration: 8000, + }); + }; + + private readonly openBrowser = async (opts: { + url?: string; + crawlerChannel?: string; + }) => { + let { url } = opts; + + if (!url) { + url = await this.getFirstBrowserUrl(); + } + this.browserLoadUrl = url; + this.browserLoadCrawlerChannel = opts.crawlerChannel; + this.openDialog = "browser"; + }; + + private async getProfile(profileId: string, signal: AbortSignal) { + return await this.api.fetch( + `/orgs/${this.orgId}/profiles/${profileId}`, + { signal }, + ); + } + + private readonly closeBrowser = () => { + this.browserLoadUrl = undefined; + this.browserLoadCrawlerChannel = undefined; + this.openDialog = undefined; + }; + + private async duplicateProfile() { + this.browserLoadUrl = await this.getFirstBrowserUrl(); + this.openDialog = "duplicate"; + } + + private async deleteProfile() { + const name_of_browser_profile = this.profile?.name + ? html`${this.profile.name}` + : undefined; + + try { + await this.api.fetch( + `/orgs/${this.orgId}/profiles/${this.profileId}`, + { + method: "DELETE", + }, + ); + + this.notify.toast({ + message: name_of_browser_profile + ? msg(html`Deleted ${name_of_browser_profile}.`) + : msg("Browser profile deleted."), + variant: "success", + icon: "check2-circle", + id: "browser-profile-status", + }); + + this.navigate.to( + `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}`, + ); + } catch (e) { + let title: string | undefined; + let message: string | TemplateResult = msg( + "Sorry, couldn't delete browser profile at this time.", + ); + + if (isApiError(e)) { + if (e.message === "profile_in_use") { + title = msg("Cannot delete browser profile in use"); + message = name_of_browser_profile + ? msg( + html`Please remove ${name_of_browser_profile} from all crawl + workflows to continue.`, + ) + : msg( + "Please remove this browser profile from all crawl workflows to continue.", + ); + } + } + + this.notify.toast({ + title, + message: message, + variant: "danger", + icon: "exclamation-octagon", + duration: 10000, + id: "browser-profile-status", + }); + } + } + + private async getWorkflows( + params: { profileId: string } & APIPaginationQuery, + signal: AbortSignal, + ) { + const query = queryString.stringify({ + ...params, + profileIds: [params.profileId], + sortBy: "lastRun", + sortDirection: SortDirection.Descending, + }); + + const data = await this.api.fetch>( + `/orgs/${this.orgId}/crawlconfigs?${query}`, + { signal }, + ); + + return data; + } +} diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index ea7e5a69a0..336eeeb88f 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -521,6 +521,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { this.isCrawler, () => emptyMessage({ + classNames: tw`border-y`, message, detail: msg( "Collections let you easily organize, replay, and share multiple crawls.", @@ -542,6 +543,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { }), () => emptyMessage({ + classNames: tw`border-y`, message, }), )} diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 33f10c6899..1780b0541c 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -160,7 +160,7 @@ export class Dashboard extends BtrixElement { class="flex items-center gap-1.5 text-pretty text-neutral-700" > @@ -300,9 +300,7 @@ export class Dashboard extends BtrixElement { ? this.renderMiscStorage(metrics) : nothing} - + ${this.renderStat({ value: metrics.archivedItemCount, singleLabel: msg("Archived Item"), @@ -364,9 +362,7 @@ export class Dashboard extends BtrixElement { class: tw`text-violet-600`, }, })} - + ${this.renderStat({ value: metrics.crawlPageCount, singleLabel: msg("Page Crawled"), diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index a02da08ed8..7ecb624afd 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -54,14 +54,13 @@ import "./archived-item-detail"; import "./archived-items"; import "./collections-list"; import "./collection-detail"; -import "./browser-profiles-detail"; import "./browser-profiles-list"; import "./settings/settings"; import "./dashboard"; +import "./browser-profiles"; import(/* webpackChunkName: "org" */ "./archived-item-qa/archived-item-qa"); import(/* webpackChunkName: "org" */ "./workflows-new"); -import(/* webpackChunkName: "org" */ "./browser-profiles-new"); const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"]; type ResourceName = (typeof RESOURCE_NAMES)[number]; @@ -86,15 +85,12 @@ export type OrgParams = { qaTab?: QATab; }; [OrgTab.BrowserProfiles]: { - browserProfileId?: string; + profileId?: string; browserId?: string; new?: ResourceName; name?: string; url?: string; - description?: string; crawlerChannel?: string; - profileId?: string; - navigateUrl?: string; proxyId?: string; }; [OrgTab.Collections]: ArchivedItemPageParams & { @@ -469,7 +465,7 @@ export class Org extends BtrixElement { const org = this.org; const proxies = this.proxiesTask.value; const crawlerChannels = this.crawlerChannelsTask.value; - const showBrowserProfileDialog = org && proxies && crawlerChannels; + const crawlingDefaultsReady = org && proxies && crawlerChannels; return html`
    - ${showBrowserProfileDialog + ${crawlingDefaultsReady ? html` (this.openDialogName = undefined)} > @@ -646,26 +635,10 @@ export class Org extends BtrixElement { private readonly renderBrowserProfiles = () => { const params = this.params as OrgParams["browser-profiles"]; - if (params.browserProfileId) { - return html``; - } - - if (params.browserId) { - return html``; + if (params.profileId) { + return html``; } return html` diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 3dabc5eda2..00b71f34af 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -46,7 +46,7 @@ export const ROUTES = { `(/${OrgTab.Workflows}(/${WorkflowTab.Crawls})(/${CommonTab.New})(/:workflowId(/:workflowTab)(/${WorkflowTab.Crawls}${archivedItemPath})))`, `(/${OrgTab.Items}(/:itemType(${archivedItemPath})))`, `(/${OrgTab.Collections}(/${CommonTab.New})(/${CommonTab.View}/:collectionId(/:collectionTab)))`, - `(/${OrgTab.BrowserProfiles}(/profile(/browser/:browserId)(/:browserProfileId)))`, + `(/${OrgTab.BrowserProfiles}(/profile(/:profileId)(/browser/:browserId)))`, `(/${OrgTab.Settings}(/:settingsTab))`, ].join(""), publicOrgs: `/${RouteNamespace.PublicOrgs}(/)`, diff --git a/frontend/src/stories/components/Code.ts b/frontend/src/stories/components/Code.ts index d57d13d828..2a768c69fc 100644 --- a/frontend/src/stories/components/Code.ts +++ b/frontend/src/stories/components/Code.ts @@ -9,12 +9,16 @@ type Language = `${Code["language"]}`; export type RenderProps = Omit & { language: Language }; -export const renderCode = ({ language, value, wrap }: Partial) => { +export const renderCode = ({ + language, + value, + noWrap, +}: Partial) => { return html` `; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 2ad070739a..d04f04cfc8 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -122,6 +122,12 @@ } @layer components { + sl-skeleton { + --border-radius: var(--sl-border-radius-small); + --color: var(--sl-color-neutral-50); + --sheen-color: var(--sl-color-neutral-100); + } + sl-avatar::part(base) { transition: var(--sl-transition-x-fast) background-color; } diff --git a/frontend/src/types/events.d.ts b/frontend/src/types/events.d.ts index 7b08caaa2d..fda2b2c6c4 100644 --- a/frontend/src/types/events.d.ts +++ b/frontend/src/types/events.d.ts @@ -2,7 +2,6 @@ import { type APIEventMap } from "@/controllers/api"; import { type CopiedEventMap } from "@/controllers/clipboard"; import { type NavigateEventMap } from "@/controllers/navigate"; import { type NotifyEventMap } from "@/controllers/notify"; -import { type UserGuideEventMap } from "@/index"; import { type AuthEventMap } from "@/utils/AuthService"; /** @@ -15,6 +14,5 @@ declare global { NotifyEventMap, AuthEventMap, APIEventMap, - UserGuideEventMap, CopiedEventMap {} } diff --git a/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts b/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts index f664f62845..5ecf3b9247 100644 --- a/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts +++ b/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts @@ -16,7 +16,7 @@ import { } from "@/types/workflow"; export type DuplicateWorkflowSettings = { - workflow: WorkflowParams; + workflow: Partial; scopeType?: ScopeType | NewWorkflowOnlyScopeType; seeds?: Seed[]; seedFile?: StorageSeedFile; @@ -27,11 +27,11 @@ export function settingsForDuplicate({ seeds, seedFile, }: { - workflow: Workflow; + workflow: Partial; seeds?: APIPaginatedList; seedFile?: StorageSeedFile; }): DuplicateWorkflowSettings { - const workflowParams: WorkflowParams = { + const workflowParams: Partial = { ...workflow, name: workflow.name ? msg(str`${workflow.name} Copy`) : "", }; @@ -42,7 +42,7 @@ export function settingsForDuplicate({ scopeType: seedFile || (seedItems?.length && seedItems.length > 1) ? NewWorkflowOnlyScopeType.PageList - : workflowParams.config.scopeType, + : workflowParams.config?.scopeType, workflow: workflowParams, seeds: seedItems, seedFile, diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index 08862130c8..83afe273c2 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -299,6 +299,58 @@ const plurals = { id: "changes.plural.other", }), }, + workflows: { + zero: msg("workflows", { + desc: 'plural form of "workflow" for zero workflows', + id: "workflows.plural.zero", + }), + one: msg("workflow", { + desc: 'singular form for "workflow"', + id: "workflows.plural.one", + }), + two: msg("workflows", { + desc: 'plural form of "workflow" for two workflows', + id: "workflows.plural.two", + }), + few: msg("workflows", { + desc: 'plural form of "workflow" for few workflows', + id: "workflows.plural.few", + }), + many: msg("workflows", { + desc: 'plural form of "workflow" for many workflows', + id: "workflows.plural.many", + }), + other: msg("workflows", { + desc: 'plural form of "workflow" for multiple/other workflows', + id: "workflows.plural.other", + }), + }, + domains: { + zero: msg("domains", { + desc: 'plural form of "domain" for zero domains', + id: "domains.plural.zero", + }), + one: msg("domain", { + desc: 'singular form for "domain"', + id: "domains.plural.one", + }), + two: msg("domains", { + desc: 'plural form of "domain" for two domains', + id: "domains.plural.two", + }), + few: msg("domains", { + desc: 'plural form of "domain" for few domains', + id: "domains.plural.few", + }), + many: msg("domains", { + desc: 'plural form of "domain" for many domains', + id: "domains.plural.many", + }), + other: msg("domains", { + desc: 'plural form of "domain" for multiple/other domains', + id: "domains.plural.other", + }), + }, }; export const pluralOf = (word: keyof typeof plurals, count: number) => { diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index f2cbb7ebd1..14621dbbef 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { getAppSettings, type AppSettings } from "./app"; import type { Tags } from "@/components/ui/tag-input"; -import type { UserGuideEventMap } from "@/index"; +import type { BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; import { Behavior, CrawlerChannelImage, @@ -73,12 +73,12 @@ export const workflowTabToGuideHash: Record = { export function makeUserGuideEvent( section: SectionsEnum, -): UserGuideEventMap["btrix-user-guide-show"] { +): BtrixUserGuideShowEvent { const userGuideHash = (workflowTabToGuideHash[section] as GuideHash | undefined) || GuideHash.Scope; - return new CustomEvent( + return new CustomEvent( "btrix-user-guide-show", { detail: {