import { localized, msg, str } from "@lit/localize"; import type { SlCheckbox } from "@shoelace-style/shoelace"; import { nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; import { guard } from "lit/directives/guard.js"; import { repeat } from "lit/directives/repeat.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; import type { PageChangeEvent } from "@/components/ui/pagination"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import type { Collection } from "@/types/collection"; import type { ArchivedItem, Crawl, CrawlState, Upload } from "@/types/crawler"; import type { AuthState } from "@/utils/AuthService"; import LiteElement, { html } from "@/utils/LiteElement"; const ABORT_REASON_THROTTLE = "throttled"; const DESCRIPTION_MAX_HEIGHT_PX = 200; const INITIAL_ITEMS_PAGE_SIZE = 20; const TABS = ["replay", "items"] as const; export type Tab = (typeof TABS)[number]; @localized() @customElement("btrix-collection-detail") export class CollectionDetail extends LiteElement { @property({ type: Object }) authState!: AuthState; @property({ type: String }) orgId!: string; @property({ type: String }) userId!: string; @property({ type: String }) collectionId!: string; @property({ type: String }) collectionTab?: Tab = TABS[0]; @property({ type: Boolean }) isCrawler?: boolean; @state() private collection?: Collection; @state() private archivedItems?: APIPaginatedList; @state() private openDialogName?: "delete" | "editMetadata" | "editItems"; @state() private isDescriptionExpanded = false; @state() private showShareInfo = false; // Use to cancel requests private getArchivedItemsController: AbortController | null = null; // TODO localize private readonly numberFormatter = new Intl.NumberFormat(undefined, { notation: "compact", }); private readonly tabLabels: Record< Tab, { icon: { name: string; library: string }; text: string } > = { replay: { icon: { name: "replaywebpage", library: "app" }, text: msg("Replay"), }, items: { icon: { name: "list-ul", library: "default" }, text: msg("Archived Items"), }, }; protected async willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("orgId")) { this.collection = undefined; void this.fetchCollection(); } if (changedProperties.has("collectionId")) { void this.fetchArchivedItems({ page: 1 }); } } protected async updated( changedProperties: PropertyValues & Map, ) { if (changedProperties.has("collection") && this.collection) { void this.checkTruncateDescription(); } } render() { return html`${this.renderHeader()}
${this.collection?.isPublic ? html` ` : html` `}

${this.collection?.name || html``}

${when( this.isCrawler || this.collection?.isPublic, () => html` (this.showShareInfo = true)} > ${msg("Share")} `, )} ${when(this.isCrawler, this.renderActions)}
${this.renderInfoBar()}
${this.renderTabs()} ${when( this.isCrawler, () => html` (this.openDialogName = "editItems")} ?disabled=${!this.collection} > ${msg("Select Items")} `, )}
${choose( this.collectionTab, [ ["replay", () => guard([this.collection], this.renderReplay)], [ "items", () => guard([this.archivedItems], this.renderArchivedItems), ], ], () => html``, )}
${this.renderDescription()}
(this.openDialogName = undefined)} > ${msg( html`Are you sure you want to delete ${this.collection?.name}?`, )}
(this.openDialogName = undefined)} >${msg("Cancel")} { await this.deleteCollection(); this.openDialogName = undefined; }} >${msg("Delete Collection")}
(this.openDialogName = undefined)} @btrix-collection-saved=${() => { void this.fetchCollection(); void this.fetchArchivedItems(); }} > ${when( this.collection, () => html` (this.openDialogName = undefined)} @btrix-collection-saved=${() => void this.fetchCollection()} > `, )} ${this.renderShareDialog()}`; } private getPublicReplayURL() { return new URL( `/api/orgs/${this.orgId}/collections/${this.collectionId}/public/replay.json`, window.location.href, ).href; } private renderShareDialog() { return html` (this.showShareInfo = false)} style="--width: 32rem;" > ${ this.collection?.isPublic ? "" : html`

${msg( "Make this collection shareable to enable a public viewing link.", )}

` } ${when( this.isCrawler, () => html`
void this.onTogglePublic((e.target as SlCheckbox).checked)} >${msg("Collection is Shareable")}
`, )} ${when(this.collection?.isPublic, this.renderShareInfo)}
(this.showShareInfo = false)} >${msg("Done")}
`; } private readonly renderShareInfo = () => { const replaySrc = this.getPublicReplayURL(); const encodedReplaySrc = encodeURIComponent(replaySrc); const publicReplayUrl = `https://replayweb.page?source=${encodedReplaySrc}`; const embedCode = ``; const importCode = `importScripts("https://replayweb.page/sw.js");`; return html` ${msg("Link to Share")}

${msg("This collection can be viewed by anyone with the link.")}

${msg("Embed Collection")}

${msg( html`Share this collection by embedding it into an existing webpage.`, )}

${msg(html`Add the following embed code to your HTML page:`)}

embedCode} content=${msg("Copy Embed Code")} >

${msg( html`Add the following JavaScript to your /replay/sw.js:`, )}

importCode} content=${msg("Copy JS")} >

${msg( html`See our embedding guide for more details.`, )}

`; }; private readonly renderHeader = () => html` `; private readonly renderTabs = () => { return html` `; }; private readonly renderActions = () => { const authToken = this.authState!.headers.Authorization.split(" ")[1]; return html` ${msg("Actions")} (this.openDialogName = "editMetadata")}> ${msg("Edit Metadata")} (this.openDialogName = "editItems")}> ${msg("Select Archived Items")} ${!this.collection?.isPublic ? html` void this.onTogglePublic(true)} > ${msg("Make Shareable")} ` : html` Visit Shareable URL void this.onTogglePublic(false)} > ${msg("Make Private")} `} ${msg("Download Collection")} ${msg("Delete Collection")} `; }; private renderInfoBar() { return html` ${this.renderDetailItem(msg("Archived Items"), (col) => col.crawlCount === 1 ? msg("1 item") : msg(str`${this.numberFormatter.format(col.crawlCount)} items`), )} ${this.renderDetailItem( msg("Total Size"), (col) => html``, )} ${this.renderDetailItem(msg("Total Pages"), (col) => col.pageCount === 1 ? msg("1 page") : msg(str`${this.numberFormatter.format(col.pageCount)} pages`), )} ${this.renderDetailItem( msg("Last Updated"), (col) => html``, )} `; } private renderDetailItem( label: string | TemplateResult, renderContent: (collection: Collection) => TemplateResult | string, ) { return html` ${when( this.collection, () => renderContent(this.collection!), () => html``, )} `; } private renderDescription() { return html`

${msg("Description")}

${when( this.isCrawler, () => html` (this.openDialogName = "editMetadata")} label=${msg("Edit description")} > `, )}
${when( this.collection, () => html`
${this.collection?.description ? html`
` : html`
${msg("No description added.")}
`}
`, () => html`
`, )}
`; } private readonly renderArchivedItems = () => html`
${when( this.archivedItems, () => { const { items, page, total, pageSize } = this.archivedItems!; const hasItems = items.length; return html`
${hasItems ? this.renderArchivedItemsList() : this.renderEmptyState()}
${when( hasItems || page > 1, () => html`
{ await this.fetchArchivedItems({ page: e.detail.page, }); // Scroll to top of list // TODO once deep-linking is implemented, scroll to top of pushstate this.scrollIntoView({ behavior: "smooth" }); }} >
`, )} `; }, () => html`
`, )}
`; private renderArchivedItemsList() { if (!this.archivedItems) return; return html` ${msg("Row actions")} ${repeat( this.archivedItems.items, ({ id }) => id, this.renderArchivedItem, )} `; } private renderEmptyState() { return html`

${this.archivedItems?.page && this.archivedItems.page > 1 ? msg("Page not found.") : msg("This Collection doesn’t have any archived items, yet.")}

`; } private readonly renderArchivedItem = ( item: ArchivedItem, idx: number, ) => html` ${this.isCrawler ? html` { // Prevent navigation to detail view e.preventDefault(); e.stopImmediatePropagation(); }} > void this.removeArchivedItem(item.id, idx)} > ${msg("Remove from Collection")} ` : nothing} `; private readonly renderReplay = () => { if (!this.collection?.crawlCount) { return this.renderEmptyState(); } const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`; const headers = this.authState?.headers; const config = JSON.stringify({ headers }); return html`
`; }; private async checkTruncateDescription() { await this.updateComplete; window.requestAnimationFrame(() => { const description = this.querySelector(".description"); if (description?.scrollHeight ?? 0 > (description?.clientHeight ?? 0)) { this.querySelector(".descriptionExpandBtn")?.classList.remove("hidden"); } }); } private readonly toggleTruncateDescription = () => { const description = this.querySelector(".description"); if (!description) { console.debug("no .description"); return; } this.isDescriptionExpanded = !this.isDescriptionExpanded; if (this.isDescriptionExpanded) { description.style.maxHeight = `${description.scrollHeight}px`; } else { description.style.maxHeight = `${DESCRIPTION_MAX_HEIGHT_PX}px`; description.closest("section")?.scrollIntoView({ behavior: "smooth", }); } }; private async onTogglePublic(isPublic: boolean) { const res = await this.apiFetch<{ updated: boolean }>( `/orgs/${this.orgId}/collections/${this.collectionId}`, this.authState!, { method: "PATCH", body: JSON.stringify({ isPublic }), }, ); if (res.updated && this.collection) { this.collection = { ...this.collection, isPublic }; } } private readonly confirmDelete = () => { this.openDialogName = "delete"; }; private async deleteCollection(): Promise { if (!this.collection) return; try { const name = this.collection.name; const _data: Crawl | Upload = await this.apiFetch( `/orgs/${this.orgId}/collections/${this.collection.id}`, this.authState!, { method: "DELETE", }, ); this.navTo(`${this.orgBasePath}/collections`); this.notify({ message: msg(html`Deleted ${name} Collection.`), variant: "success", icon: "check2-circle", }); } catch { this.notify({ message: msg("Sorry, couldn't delete Collection at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async fetchCollection() { try { this.collection = await this.getCollection(); } catch (e) { this.notify({ message: msg("Sorry, couldn't retrieve Collection at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async getCollection() { const data = await this.apiFetch( `/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`, this.authState!, ); return data; } /** * Fetch web captures and update internal state */ private async fetchArchivedItems(params?: APIPaginationQuery): Promise { this.cancelInProgressGetArchivedItems(); try { this.archivedItems = await this.getArchivedItems(params); } catch (e) { if ((e as Error).name === "AbortError") { console.debug("Fetch web captures aborted to throttle"); } else { this.notify({ message: msg("Sorry, couldn't retrieve web captures at this time."), variant: "danger", icon: "exclamation-octagon", }); } } } private cancelInProgressGetArchivedItems() { if (this.getArchivedItemsController) { this.getArchivedItemsController.abort(ABORT_REASON_THROTTLE); this.getArchivedItemsController = null; } } private async getArchivedItems( params?: Partial<{ state: CrawlState[]; }> & APIPaginationQuery & APISortQuery, ) { const query = queryString.stringify( { ...params, page: params?.page || this.archivedItems?.page || 1, pageSize: params?.pageSize || this.archivedItems?.pageSize || INITIAL_ITEMS_PAGE_SIZE, }, { arrayFormat: "comma", }, ); const data = await this.apiFetch>( `/orgs/${this.orgId}/all-crawls?collectionId=${this.collectionId}&${query}`, this.authState!, ); return data; } private async removeArchivedItem(id: string, _pageIndex: number) { try { await this.apiFetch( `/orgs/${this.orgId}/collections/${this.collectionId}/remove`, this.authState!, { method: "POST", body: JSON.stringify({ crawlIds: [id] }), }, ); const { page, items } = this.archivedItems!; this.notify({ message: msg(str`Successfully removed item from Collection.`), variant: "success", icon: "check2-circle", }); void this.fetchCollection(); void this.fetchArchivedItems({ // Update page if last item page: items.length === 1 && page > 1 ? page - 1 : page, }); } catch (e) { console.debug((e as Error | undefined)?.message); this.notify({ message: msg( "Sorry, couldn't remove item from Collection at this time.", ), variant: "danger", icon: "exclamation-octagon", }); } } }