Closes #424 ### Changes - Adds Browsertrix logo to the nav bar!! - Will license the typeface (Konsole) as soon as this gets merged. - Changes the theme colour to Webrecorder's turquoise brand colour - Adjusts the usage of `primary` vs `blue` for elements where it shouldn't be the same - Changes the _Cancel & Discard_ button of the cancel crawl dialogue to `danger` button variant - Replaces the Replay icon with the new ReplayWebpage icon! - Fixes the favicon SVG linking (wrong name, oops!) - Fills the microscope lens in the microscope icon - Removes the old btrix-cloud.svg logo Co-authored-by: sua yoo <sua@webrecorder.org>
917 lines
28 KiB
TypeScript
917 lines
28 KiB
TypeScript
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<ArchivedItem>;
|
||
|
||
@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<this>) {
|
||
if (changedProperties.has("orgId")) {
|
||
this.collection = undefined;
|
||
void this.fetchCollection();
|
||
}
|
||
if (changedProperties.has("collectionId")) {
|
||
void this.fetchArchivedItems({ page: 1 });
|
||
}
|
||
}
|
||
|
||
protected async updated(
|
||
changedProperties: PropertyValues<this> & Map<string, unknown>,
|
||
) {
|
||
if (changedProperties.has("collection") && this.collection) {
|
||
void this.checkTruncateDescription();
|
||
}
|
||
}
|
||
|
||
render() {
|
||
return html`${this.renderHeader()}
|
||
<header class="items-center gap-2 pb-3 md:flex">
|
||
<div class="mb-2 flex w-full items-center gap-2 md:mb-0">
|
||
${this.collection?.isPublic
|
||
? html`
|
||
<sl-tooltip content=${msg("Shareable")}>
|
||
<sl-icon
|
||
class="text-lg text-success-600"
|
||
name="people-fill"
|
||
></sl-icon>
|
||
</sl-tooltip>
|
||
`
|
||
: html`
|
||
<sl-tooltip content=${msg("Private")}>
|
||
<sl-icon class="text-lg" name="eye-slash-fill"></sl-icon>
|
||
</sl-tooltip>
|
||
`}
|
||
<h1 class="min-w-0 flex-1 truncate text-xl font-semibold leading-7">
|
||
${this.collection?.name ||
|
||
html`<sl-skeleton class="w-96"></sl-skeleton>`}
|
||
</h1>
|
||
</div>
|
||
${when(
|
||
this.isCrawler || this.collection?.isPublic,
|
||
() => html`
|
||
<sl-button
|
||
variant=${this.collection?.crawlCount ? "primary" : "default"}
|
||
size="small"
|
||
@click=${() => (this.showShareInfo = true)}
|
||
>
|
||
<sl-icon name="box-arrow-up" slot="prefix"></sl-icon>
|
||
${msg("Share")}
|
||
</sl-button>
|
||
`,
|
||
)}
|
||
${when(this.isCrawler, this.renderActions)}
|
||
</header>
|
||
<div class="mb-3 rounded-lg border px-4 py-2">
|
||
${this.renderInfoBar()}
|
||
</div>
|
||
<div class="mb-3 flex items-center justify-between">
|
||
${this.renderTabs()}
|
||
${when(
|
||
this.isCrawler,
|
||
() => html`
|
||
<sl-button
|
||
variant=${!this.collection || this.collection.crawlCount
|
||
? "default"
|
||
: "primary"}
|
||
size="small"
|
||
@click=${() => (this.openDialogName = "editItems")}
|
||
?disabled=${!this.collection}
|
||
>
|
||
<sl-icon name="ui-checks" slot="prefix"></sl-icon>
|
||
${msg("Select Items")}
|
||
</sl-button>
|
||
`,
|
||
)}
|
||
</div>
|
||
${choose(
|
||
this.collectionTab,
|
||
[
|
||
["replay", () => guard([this.collection], this.renderReplay)],
|
||
[
|
||
"items",
|
||
() => guard([this.archivedItems], this.renderArchivedItems),
|
||
],
|
||
],
|
||
|
||
() => html`<btrix-not-found></btrix-not-found>`,
|
||
)}
|
||
<div class="my-7">${this.renderDescription()}</div>
|
||
|
||
<btrix-dialog
|
||
label=${msg("Delete Collection?")}
|
||
?open=${this.openDialogName === "delete"}
|
||
@sl-hide=${() => (this.openDialogName = undefined)}
|
||
>
|
||
${msg(
|
||
html`Are you sure you want to delete
|
||
<strong>${this.collection?.name}</strong>?`,
|
||
)}
|
||
<div slot="footer" class="flex justify-between">
|
||
<sl-button
|
||
size="small"
|
||
@click=${() => (this.openDialogName = undefined)}
|
||
>${msg("Cancel")}</sl-button
|
||
>
|
||
<sl-button
|
||
size="small"
|
||
variant="primary"
|
||
@click=${async () => {
|
||
await this.deleteCollection();
|
||
this.openDialogName = undefined;
|
||
}}
|
||
>${msg("Delete Collection")}</sl-button
|
||
>
|
||
</div>
|
||
</btrix-dialog>
|
||
<btrix-collection-items-dialog
|
||
orgId=${this.orgId}
|
||
userId=${this.userId}
|
||
collectionId=${this.collectionId}
|
||
collectionName=${this.collection?.name || ""}
|
||
.authState=${this.authState}
|
||
?isCrawler=${this.isCrawler}
|
||
?open=${this.openDialogName === "editItems"}
|
||
@sl-hide=${() => (this.openDialogName = undefined)}
|
||
@btrix-collection-saved=${() => {
|
||
void this.fetchCollection();
|
||
void this.fetchArchivedItems();
|
||
}}
|
||
>
|
||
</btrix-collection-items-dialog>
|
||
${when(
|
||
this.collection,
|
||
() => html`
|
||
<btrix-collection-metadata-dialog
|
||
orgId=${this.orgId}
|
||
.authState=${this.authState}
|
||
.collection=${this.collection!}
|
||
?open=${this.openDialogName === "editMetadata"}
|
||
@sl-hide=${() => (this.openDialogName = undefined)}
|
||
@btrix-collection-saved=${() => void this.fetchCollection()}
|
||
>
|
||
</btrix-collection-metadata-dialog>
|
||
`,
|
||
)}
|
||
${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`
|
||
<btrix-dialog
|
||
.label=${msg("Share Collection")}
|
||
.open=${this.showShareInfo}
|
||
@sl-hide=${() => (this.showShareInfo = false)}
|
||
style="--width: 32rem;"
|
||
>
|
||
${
|
||
this.collection?.isPublic
|
||
? ""
|
||
: html`<p class="mb-3">
|
||
${msg(
|
||
"Make this collection shareable to enable a public viewing link.",
|
||
)}
|
||
</p>`
|
||
}
|
||
${when(
|
||
this.isCrawler,
|
||
() => html`
|
||
<div class="mb-5">
|
||
<sl-switch
|
||
?checked=${this.collection?.isPublic}
|
||
@sl-change=${(e: CustomEvent) =>
|
||
void this.onTogglePublic((e.target as SlCheckbox).checked)}
|
||
>${msg("Collection is Shareable")}</sl-switch
|
||
>
|
||
</div>
|
||
`,
|
||
)}
|
||
</div>
|
||
${when(this.collection?.isPublic, this.renderShareInfo)}
|
||
<div slot="footer" class="flex justify-end">
|
||
<sl-button size="small" @click=${() => (this.showShareInfo = false)}
|
||
>${msg("Done")}</sl-button
|
||
>
|
||
</div>
|
||
</btrix-dialog>
|
||
`;
|
||
}
|
||
|
||
private readonly renderShareInfo = () => {
|
||
const replaySrc = this.getPublicReplayURL();
|
||
const encodedReplaySrc = encodeURIComponent(replaySrc);
|
||
const publicReplayUrl = `https://replayweb.page?source=${encodedReplaySrc}`;
|
||
const embedCode = `<replay-web-page source="${replaySrc}"></replay-web-page>`;
|
||
const importCode = `importScripts("https://replayweb.page/sw.js");`;
|
||
|
||
return html` <btrix-section-heading
|
||
>${msg("Link to Share")}</btrix-section-heading
|
||
>
|
||
<section class="mb-5 mt-3">
|
||
<p class="mb-3">
|
||
${msg("This collection can be viewed by anyone with the link.")}
|
||
</p>
|
||
|
||
<btrix-copy-field
|
||
class="mb-2"
|
||
.value="${publicReplayUrl}"
|
||
hideContentFromScreenReaders
|
||
>
|
||
<sl-tooltip slot="prefix" content=${msg("Open in New Tab")}>
|
||
<sl-icon-button
|
||
href=${publicReplayUrl}
|
||
name="box-arrow-up-right"
|
||
target="_blank"
|
||
>
|
||
</sl-icon-button>
|
||
</sl-tooltip>
|
||
</btrix-copy-field>
|
||
</section>
|
||
<btrix-section-heading>${msg("Embed Collection")}</btrix-section-heading>
|
||
<section class="mt-3">
|
||
<p class="mb-3">
|
||
${msg(
|
||
html`Share this collection by embedding it into an existing webpage.`,
|
||
)}
|
||
</p>
|
||
<p class="mb-3">
|
||
${msg(html`Add the following embed code to your HTML page:`)}
|
||
</p>
|
||
<div class="relative mb-5 rounded border bg-slate-50 p-3 pr-9">
|
||
<btrix-code value=${embedCode}></btrix-code>
|
||
<div
|
||
class="absolute right-1.5 top-1.5 rounded border bg-white shadow-sm"
|
||
>
|
||
<btrix-copy-button
|
||
.getValue=${() => embedCode}
|
||
content=${msg("Copy Embed Code")}
|
||
></btrix-copy-button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-3">
|
||
${msg(
|
||
html`Add the following JavaScript to your
|
||
<code class="text-[0.9em]">/replay/sw.js</code>:`,
|
||
)}
|
||
</p>
|
||
<div class="relative mb-5 rounded border bg-slate-50 p-3 pr-9">
|
||
<btrix-code language="javascript" value=${importCode}></btrix-code>
|
||
<div
|
||
class="absolute right-1.5 top-1.5 rounded border bg-white shadow-sm"
|
||
>
|
||
<btrix-copy-button
|
||
.getValue=${() => importCode}
|
||
content=${msg("Copy JS")}
|
||
></btrix-copy-button>
|
||
</div>
|
||
</div>
|
||
<p>
|
||
${msg(
|
||
html`See
|
||
<a
|
||
class="text-primary"
|
||
href="https://replayweb.page/docs/embedding"
|
||
target="_blank"
|
||
>
|
||
our embedding guide</a
|
||
>
|
||
for more details.`,
|
||
)}
|
||
</p>
|
||
</section>`;
|
||
};
|
||
|
||
private readonly renderHeader = () => html`
|
||
<nav class="mb-7">
|
||
<a
|
||
class="text-sm font-medium text-gray-600 hover:text-gray-800"
|
||
href=${`${this.orgBasePath}/collections`}
|
||
@click=${this.navLink}
|
||
>
|
||
<sl-icon name="arrow-left" class="inline-block align-middle"></sl-icon>
|
||
<span class="inline-block align-middle"
|
||
>${msg("Back to Collections")}</span
|
||
>
|
||
</a>
|
||
</nav>
|
||
`;
|
||
|
||
private readonly renderTabs = () => {
|
||
return html`
|
||
<nav class="flex gap-2">
|
||
${TABS.map((tabName) => {
|
||
const isSelected = tabName === this.collectionTab;
|
||
return html`
|
||
<btrix-navigation-button
|
||
.active=${isSelected}
|
||
aria-selected="${isSelected}"
|
||
href=${`${this.orgBasePath}/collections/view/${this.collectionId}/${tabName}`}
|
||
@click=${this.navLink}
|
||
>
|
||
<sl-icon
|
||
name=${this.tabLabels[tabName].icon.name}
|
||
library=${this.tabLabels[tabName].icon.library}
|
||
></sl-icon>
|
||
${this.tabLabels[tabName].text}</btrix-navigation-button
|
||
>
|
||
`;
|
||
})}
|
||
</nav>
|
||
`;
|
||
};
|
||
|
||
private readonly renderActions = () => {
|
||
const authToken = this.authState!.headers.Authorization.split(" ")[1];
|
||
|
||
return html`
|
||
<sl-dropdown distance="4">
|
||
<sl-button slot="trigger" size="small" caret
|
||
>${msg("Actions")}</sl-button
|
||
>
|
||
<sl-menu>
|
||
<sl-menu-item @click=${() => (this.openDialogName = "editMetadata")}>
|
||
<sl-icon name="pencil" slot="prefix"></sl-icon>
|
||
${msg("Edit Metadata")}
|
||
</sl-menu-item>
|
||
<sl-menu-item @click=${() => (this.openDialogName = "editItems")}>
|
||
<sl-icon name="ui-checks" slot="prefix"></sl-icon>
|
||
${msg("Select Archived Items")}
|
||
</sl-menu-item>
|
||
<sl-divider></sl-divider>
|
||
${!this.collection?.isPublic
|
||
? html`
|
||
<sl-menu-item
|
||
style="--sl-color-neutral-700: var(--success)"
|
||
@click=${() => void this.onTogglePublic(true)}
|
||
>
|
||
<sl-icon name="people-fill" slot="prefix"></sl-icon>
|
||
${msg("Make Shareable")}
|
||
</sl-menu-item>
|
||
`
|
||
: html`
|
||
<sl-menu-item style="--sl-color-neutral-700: var(--success)">
|
||
<sl-icon name="box-arrow-up-right" slot="prefix"></sl-icon>
|
||
<a
|
||
target="_blank"
|
||
slot="prefix"
|
||
href="https://replayweb.page?source=${this.getPublicReplayURL()}"
|
||
>
|
||
Visit Shareable URL
|
||
</a>
|
||
</sl-menu-item>
|
||
<sl-menu-item
|
||
style="--sl-color-neutral-700: var(--warning)"
|
||
@click=${() => void this.onTogglePublic(false)}
|
||
>
|
||
<sl-icon name="eye-slash" slot="prefix"></sl-icon>
|
||
${msg("Make Private")}
|
||
</sl-menu-item>
|
||
`}
|
||
<btrix-menu-item-link
|
||
href=${`/api/orgs/${this.orgId}/collections/${this.collectionId}/download?auth_bearer=${authToken}`}
|
||
download
|
||
>
|
||
<sl-icon name="cloud-download" slot="prefix"></sl-icon>
|
||
${msg("Download Collection")}
|
||
</btrix-menu-item-link>
|
||
<sl-divider></sl-divider>
|
||
<sl-menu-item
|
||
style="--sl-color-neutral-700: var(--danger)"
|
||
@click=${this.confirmDelete}
|
||
>
|
||
<sl-icon name="trash3" slot="prefix"></sl-icon>
|
||
${msg("Delete Collection")}
|
||
</sl-menu-item>
|
||
</sl-menu>
|
||
</sl-dropdown>
|
||
`;
|
||
};
|
||
|
||
private renderInfoBar() {
|
||
return html`
|
||
<btrix-desc-list horizontal>
|
||
${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`<sl-format-bytes
|
||
value=${col.totalSize || 0}
|
||
display="narrow"
|
||
></sl-format-bytes>`,
|
||
)}
|
||
${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`<sl-format-date
|
||
date=${`${col.modified}Z`}
|
||
month="2-digit"
|
||
day="2-digit"
|
||
year="2-digit"
|
||
hour="2-digit"
|
||
minute="2-digit"
|
||
></sl-format-date>`,
|
||
)}
|
||
</btrix-desc-list>
|
||
`;
|
||
}
|
||
|
||
private renderDetailItem(
|
||
label: string | TemplateResult,
|
||
renderContent: (collection: Collection) => TemplateResult | string,
|
||
) {
|
||
return html`
|
||
<btrix-desc-list-item label=${label}>
|
||
${when(
|
||
this.collection,
|
||
() => renderContent(this.collection!),
|
||
() => html`<sl-skeleton class="w-full"></sl-skeleton>`,
|
||
)}
|
||
</btrix-desc-list-item>
|
||
`;
|
||
}
|
||
|
||
private renderDescription() {
|
||
return html`
|
||
<section>
|
||
<header class="flex items-center justify-between">
|
||
<h2 class="mb-1 h-8 min-h-fit text-lg font-semibold leading-none">
|
||
${msg("Description")}
|
||
</h2>
|
||
${when(
|
||
this.isCrawler,
|
||
() => html`
|
||
<sl-icon-button
|
||
class="text-base"
|
||
name="pencil"
|
||
@click=${() => (this.openDialogName = "editMetadata")}
|
||
label=${msg("Edit description")}
|
||
></sl-icon-button>
|
||
`,
|
||
)}
|
||
</header>
|
||
<main>
|
||
${when(
|
||
this.collection,
|
||
() => html`
|
||
<main class="rounded-lg border">
|
||
${this.collection?.description
|
||
? html`<div
|
||
class="description mx-auto max-w-prose overflow-hidden py-5 transition-all"
|
||
style=${`max-height: ${DESCRIPTION_MAX_HEIGHT_PX}px`}
|
||
>
|
||
<btrix-markdown-viewer
|
||
value=${this.collection.description}
|
||
></btrix-markdown-viewer>
|
||
</div>
|
||
<div
|
||
role="button"
|
||
class="descriptionExpandBtn hidden border-t p-2 text-right font-medium text-neutral-500 transition-colors hover:bg-neutral-50"
|
||
@click=${this.toggleTruncateDescription}
|
||
>
|
||
<span class="mr-1 inline-block align-middle"
|
||
>${this.isDescriptionExpanded
|
||
? msg("Less")
|
||
: msg("More")}</span
|
||
>
|
||
<sl-icon
|
||
class="inline-block align-middle text-base"
|
||
name=${this.isDescriptionExpanded
|
||
? "chevron-double-up"
|
||
: "chevron-double-down"}
|
||
></sl-icon>
|
||
</div> `
|
||
: html`<div class="p-5 text-center text-neutral-400">
|
||
${msg("No description added.")}
|
||
</div>`}
|
||
</main>
|
||
`,
|
||
() =>
|
||
html`<div
|
||
class="flex items-center justify-center rounded border text-3xl"
|
||
style=${`max-height: ${DESCRIPTION_MAX_HEIGHT_PX}px`}
|
||
>
|
||
<sl-spinner></sl-spinner>
|
||
</div>`,
|
||
)}
|
||
</main>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
private readonly renderArchivedItems = () =>
|
||
html`<section>
|
||
${when(
|
||
this.archivedItems,
|
||
() => {
|
||
const { items, page, total, pageSize } = this.archivedItems!;
|
||
const hasItems = items.length;
|
||
return html`
|
||
<section>
|
||
${hasItems
|
||
? this.renderArchivedItemsList()
|
||
: this.renderEmptyState()}
|
||
</section>
|
||
${when(
|
||
hasItems || page > 1,
|
||
() => html`
|
||
<footer class="mt-6 flex justify-center">
|
||
<btrix-pagination
|
||
page=${page}
|
||
totalCount=${total}
|
||
size=${pageSize}
|
||
@page-change=${async (e: PageChangeEvent) => {
|
||
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" });
|
||
}}
|
||
></btrix-pagination>
|
||
</footer>
|
||
`,
|
||
)}
|
||
`;
|
||
},
|
||
() => html`
|
||
<div class="my-12 flex w-full items-center justify-center text-2xl">
|
||
<sl-spinner></sl-spinner>
|
||
</div>
|
||
`,
|
||
)}
|
||
</section>`;
|
||
|
||
private renderArchivedItemsList() {
|
||
if (!this.archivedItems) return;
|
||
|
||
return html`
|
||
<btrix-archived-item-list>
|
||
<btrix-table-header-cell slot="actionCell" class="px-1">
|
||
<span class="sr-only">${msg("Row actions")}</span>
|
||
</btrix-table-header-cell>
|
||
${repeat(
|
||
this.archivedItems.items,
|
||
({ id }) => id,
|
||
this.renderArchivedItem,
|
||
)}
|
||
</btrix-archived-item-list>
|
||
`;
|
||
}
|
||
|
||
private renderEmptyState() {
|
||
return html`
|
||
<div class="rounded border p-5">
|
||
<p class="text-center text-neutral-500">
|
||
${this.archivedItems?.page && this.archivedItems.page > 1
|
||
? msg("Page not found.")
|
||
: msg("This Collection doesn’t have any archived items, yet.")}
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private readonly renderArchivedItem = (
|
||
item: ArchivedItem,
|
||
idx: number,
|
||
) => html`
|
||
<btrix-archived-item-list-item
|
||
href=${`/orgs/${this.appState.orgSlug}/items/${item.type}/${item.id}?collectionId=${this.collectionId}`}
|
||
.item=${item}
|
||
>
|
||
<btrix-crawl-status
|
||
slot="namePrefix"
|
||
state=${item.state}
|
||
hideLabel
|
||
type=${item.type}
|
||
></btrix-crawl-status>
|
||
${this.isCrawler
|
||
? html`
|
||
<btrix-table-cell slot="actionCell" class="px-1">
|
||
<btrix-overflow-dropdown
|
||
@click=${(e: MouseEvent) => {
|
||
// Prevent navigation to detail view
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
}}
|
||
>
|
||
<sl-menu>
|
||
<sl-menu-item
|
||
style="--sl-color-neutral-700: var(--warning)"
|
||
@click=${() => void this.removeArchivedItem(item.id, idx)}
|
||
>
|
||
<sl-icon name="folder-minus" slot="prefix"></sl-icon>
|
||
${msg("Remove from Collection")}
|
||
</sl-menu-item>
|
||
</sl-menu>
|
||
</btrix-overflow-dropdown>
|
||
</btrix-table-cell>
|
||
`
|
||
: nothing}
|
||
</btrix-archived-item-list-item>
|
||
`;
|
||
|
||
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`<section>
|
||
<main>
|
||
<div class="aspect-4/3 overflow-hidden rounded-lg border">
|
||
<replay-web-page
|
||
source=${replaySource}
|
||
replayBase="/replay/"
|
||
config="${config}"
|
||
noSandbox="true"
|
||
noCache="true"
|
||
></replay-web-page>
|
||
</div>
|
||
</main>
|
||
</section>`;
|
||
};
|
||
|
||
private async checkTruncateDescription() {
|
||
await this.updateComplete;
|
||
window.requestAnimationFrame(() => {
|
||
const description = this.querySelector<HTMLElement>(".description");
|
||
if (description?.scrollHeight ?? 0 > (description?.clientHeight ?? 0)) {
|
||
this.querySelector(".descriptionExpandBtn")?.classList.remove("hidden");
|
||
}
|
||
});
|
||
}
|
||
|
||
private readonly toggleTruncateDescription = () => {
|
||
const description = this.querySelector<HTMLElement>(".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<void> {
|
||
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 <strong>${name}</strong> 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<Collection>(
|
||
`/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<void> {
|
||
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<APIPaginatedList<Crawl | Upload>>(
|
||
`/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",
|
||
});
|
||
}
|
||
}
|
||
}
|