@@ -181,6 +194,7 @@ export class MarkdownEditor extends BtrixElement {
taskList: false,
upload: false,
},
+ placeholder: this.placeholder,
});
}
}
diff --git a/frontend/src/components/ui/markdown-viewer.ts b/frontend/src/components/ui/markdown-viewer.ts
index f3ee86dc9e..6ea8f94e32 100644
--- a/frontend/src/components/ui/markdown-viewer.ts
+++ b/frontend/src/components/ui/markdown-viewer.ts
@@ -29,6 +29,18 @@ export class MarkdownViewer extends LitElement {
img {
max-width: 100%;
}
+
+ p {
+ line-height: inherit;
+ }
+
+ p:first-child {
+ margin-top: 0;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
`,
];
diff --git a/frontend/src/components/ui/overflow-dropdown.ts b/frontend/src/components/ui/overflow-dropdown.ts
index ac9f40a6e3..862ea0b568 100644
--- a/frontend/src/components/ui/overflow-dropdown.ts
+++ b/frontend/src/components/ui/overflow-dropdown.ts
@@ -3,10 +3,12 @@ import type { SlDropdown, SlMenu } from "@shoelace-style/shoelace";
import { html } from "lit";
import {
customElement,
+ property,
query,
queryAssignedElements,
state,
} from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
import { TailwindElement } from "@/classes/TailwindElement";
@@ -26,6 +28,9 @@ import { TailwindElement } from "@/classes/TailwindElement";
@localized()
@customElement("btrix-overflow-dropdown")
export class OverflowDropdown extends TailwindElement {
+ @property({ type: Boolean })
+ raised = false;
+
@state()
private hasMenuItems?: boolean;
@@ -37,15 +42,19 @@ export class OverflowDropdown extends TailwindElement {
render() {
return html`
-
-
-
+
+
+
+
+
(this.hasMenuItems = this.menu.length > 0)}
>
diff --git a/frontend/src/components/ui/section-heading.ts b/frontend/src/components/ui/section-heading.ts
index ffb4808150..08705ffa5b 100644
--- a/frontend/src/components/ui/section-heading.ts
+++ b/frontend/src/components/ui/section-heading.ts
@@ -19,8 +19,7 @@ export class SectionHeading extends LitElement {
gap: 0.5rem;
font-size: var(--sl-font-size-medium);
color: var(--sl-color-neutral-500);
- padding-top: var(--sl-spacing-x-small);
- padding-bottom: var(--sl-spacing-x-small);
+ min-height: 2rem;
line-height: 1;
border-bottom: 1px solid var(--sl-panel-border-color);
margin-bottom: var(--margin);
diff --git a/frontend/src/components/ui/table/table.ts b/frontend/src/components/ui/table/table.ts
index 091932ce6a..aff04f8ebc 100644
--- a/frontend/src/components/ui/table/table.ts
+++ b/frontend/src/components/ui/table/table.ts
@@ -48,6 +48,7 @@ tableCSS.split("}").forEach((rule: string) => {
* @slot head
* @slot
* @csspart head
+ * @cssproperty --btrix-column-gap
* @cssproperty --btrix-cell-gap
* @cssproperty --btrix-cell-padding-top
* @cssproperty --btrix-cell-padding-left
@@ -58,6 +59,7 @@ tableCSS.split("}").forEach((rule: string) => {
export class Table extends LitElement {
static styles = css`
:host {
+ --btrix-column-gap: 0;
--btrix-cell-gap: 0;
--btrix-cell-padding-top: 0;
--btrix-cell-padding-bottom: 0;
@@ -65,6 +67,7 @@ export class Table extends LitElement {
--btrix-cell-padding-right: 0;
display: grid;
+ column-gap: var(--btrix-column-gap, 0);
}
`;
diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts
index 4a43f72ee2..44c9e44030 100644
--- a/frontend/src/controllers/api.ts
+++ b/frontend/src/controllers/api.ts
@@ -1,5 +1,6 @@
import { msg } from "@lit/localize";
import type { ReactiveController, ReactiveControllerHost } from "lit";
+import throttle from "lodash/fp/throttle";
import { APIError, type Detail } from "@/utils/api";
import AuthService from "@/utils/AuthService";
@@ -12,6 +13,11 @@ export interface APIEventMap {
"btrix-storage-quota-update": CustomEvent;
}
+export enum AbortReason {
+ UserCancel = "user-canceled",
+ QuotaReached = "storage_quota_reached",
+}
+
/**
* Utilities for interacting with the Browsertrix backend API
*
@@ -29,13 +35,20 @@ export interface APIEventMap {
export class APIController implements ReactiveController {
host: ReactiveControllerHost & EventTarget;
+ uploadProgress = 0;
+
+ private uploadRequest: XMLHttpRequest | null = null;
+
constructor(host: APIController["host"]) {
this.host = host;
host.addController(this);
}
hostConnected() {}
- hostDisconnected() {}
+
+ hostDisconnected() {
+ this.cancelUpload();
+ }
async fetch(path: string, options?: RequestInit): Promise {
const auth = appState.auth;
@@ -156,4 +169,74 @@ export class APIController implements ReactiveController {
details: errorDetail as Detail[],
});
}
+
+ async upload(
+ path: string,
+ file: File,
+ ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> {
+ const auth = appState.auth;
+
+ if (!auth) throw new Error("auth not in state");
+
+ // TODO handle multiple uploads
+ if (this.uploadRequest) {
+ console.debug("upload request exists");
+ this.cancelUpload();
+ }
+
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open("PUT", `/api/${path}`);
+ xhr.setRequestHeader("Content-Type", "application/octet-stream");
+ Object.entries(auth.headers).forEach(([k, v]) => {
+ xhr.setRequestHeader(k, v);
+ });
+ xhr.addEventListener("load", () => {
+ if (xhr.status === 200) {
+ resolve(
+ JSON.parse(xhr.response as string) as {
+ id: string;
+ added: boolean;
+ storageQuotaReached: boolean;
+ },
+ );
+ }
+ if (xhr.status === 403) {
+ reject(AbortReason.QuotaReached);
+ }
+ });
+ xhr.addEventListener("error", () => {
+ reject(
+ new APIError({
+ message: xhr.statusText,
+ status: xhr.status,
+ }),
+ );
+ });
+ xhr.addEventListener("abort", () => {
+ reject(AbortReason.UserCancel);
+ });
+ xhr.upload.addEventListener("progress", this.onUploadProgress);
+
+ xhr.send(file);
+
+ this.uploadRequest = xhr;
+ });
+ }
+
+ readonly onUploadProgress = throttle(100)((e: ProgressEvent) => {
+ this.uploadProgress = (e.loaded / e.total) * 100;
+
+ this.host.requestUpdate();
+ });
+
+ private cancelUpload() {
+ if (this.uploadRequest) {
+ this.uploadRequest.abort();
+ this.uploadRequest = null;
+ }
+
+ this.onUploadProgress.cancel();
+ }
}
diff --git a/frontend/src/controllers/navigate.ts b/frontend/src/controllers/navigate.ts
index 6e94f56b45..a20c516976 100644
--- a/frontend/src/controllers/navigate.ts
+++ b/frontend/src/controllers/navigate.ts
@@ -37,6 +37,12 @@ export class NavigateController implements ReactiveController {
return "/";
}
+ get isPublicPage() {
+ return window.location.pathname.startsWith(
+ `/${RouteNamespace.PublicOrgs}/`,
+ );
+ }
+
constructor(host: NavigateController["host"]) {
this.host = host;
host.addController(this);
diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts
index a242c7d30f..b319e44767 100644
--- a/frontend/src/features/archived-items/file-uploader.ts
+++ b/frontend/src/features/archived-items/file-uploader.ts
@@ -44,6 +44,8 @@ enum AbortReason {
* >
* ```
*
+ * @TODO Refactor to use this.api.upload
+ *
* @event request-close
* @event upload-start
* @event uploaded
diff --git a/frontend/src/features/collections/collection-items-dialog.ts b/frontend/src/features/collections/collection-items-dialog.ts
index 29b6e162e8..43ef8d4083 100644
--- a/frontend/src/features/collections/collection-items-dialog.ts
+++ b/frontend/src/features/collections/collection-items-dialog.ts
@@ -674,7 +674,7 @@ export class CollectionItemsDialog extends BtrixElement {
this.close();
this.dispatchEvent(new CustomEvent("btrix-collection-saved"));
this.notify.toast({
- message: msg(str`Successfully saved archived item selection.`),
+ message: msg(str`Archived item selection updated.`),
variant: "success",
icon: "check2-circle",
id: "archived-item-selection-status",
@@ -683,7 +683,7 @@ export class CollectionItemsDialog extends BtrixElement {
this.notify.toast({
message: isApiError(e)
? e.message
- : msg("Something unexpected went wrong"),
+ : msg("Sorry, couldn't save archived item selection at this time."),
variant: "danger",
icon: "exclamation-octagon",
id: "archived-item-selection-status",
diff --git a/frontend/src/features/collections/collection-metadata-dialog.ts b/frontend/src/features/collections/collection-metadata-dialog.ts
index 49173172a2..743c9323c6 100644
--- a/frontend/src/features/collections/collection-metadata-dialog.ts
+++ b/frontend/src/features/collections/collection-metadata-dialog.ts
@@ -1,7 +1,7 @@
import { localized, msg, str } from "@lit/localize";
-import { type SlInput } from "@shoelace-style/shoelace";
+import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
-import { html } from "lit";
+import { html, nothing } from "lit";
import {
customElement,
property,
@@ -11,9 +11,10 @@ import {
} from "lit/decorators.js";
import { when } from "lit/directives/when.js";
+import { DEFAULT_THUMBNAIL } from "./collection-thumbnail";
+
import { BtrixElement } from "@/classes/BtrixElement";
import type { Dialog } from "@/components/ui/dialog";
-import type { MarkdownEditor } from "@/components/ui/markdown-editor";
import type { SelectCollectionAccess } from "@/features/collections/select-collection-access";
import { CollectionAccess, type Collection } from "@/types/collection";
import { isApiError } from "@/utils/api";
@@ -41,8 +42,8 @@ export class CollectionMetadataDialog extends BtrixElement {
@state()
private isSubmitting = false;
- @query("btrix-markdown-editor")
- private readonly descriptionEditor?: MarkdownEditor | null;
+ @state()
+ private showPublicWarning = false;
@query("btrix-select-collection-access")
private readonly selectCollectionAccess?: SelectCollectionAccess | null;
@@ -51,6 +52,7 @@ export class CollectionMetadataDialog extends BtrixElement {
private readonly form!: Promise;
private readonly validateNameMax = maxLengthValidator(50);
+ private readonly validateCaptionMax = maxLengthValidator(150);
protected firstUpdated(): void {
if (this.open) {
@@ -61,12 +63,12 @@ export class CollectionMetadataDialog extends BtrixElement {
render() {
return html` (this.isDialogVisible = true)}
@sl-after-hide=${() => (this.isDialogVisible = false)}
- style="--width: 46rem"
+ class="[--width:40rem]"
>
${when(this.isDialogVisible, () => this.renderForm())}
@@ -80,14 +82,6 @@ export class CollectionMetadataDialog extends BtrixElement {
}}
>${msg("Cancel")}
- ${when(
- !this.collection,
- () => html`
-
- `,
- )}
-
-
+ >
+
+
+
+ ${msg("Summary")}
+
+
+ ${msg(
+ "Write a short description that summarizes this collection. If the collection is public, this description will be visible next to the collection name.",
+ )}
+ ${this.collection
+ ? nothing
+ : msg(
+ "You can write a longer description in the 'About' section after creating the collection.",
+ )}
+
+
+
+
+
${when(
!this.collection,
() => html`
-
+
+ (this.showPublicWarning =
+ (e.detail.item.value as CollectionAccess) ===
+ CollectionAccess.Public)}
+ >
+ `,
+ )}
+ ${when(
+ this.showPublicWarning && this.org,
+ (org) => html`
+
+ ${org.enablePublicProfile
+ ? msg(
+ "This collection will be visible on the org public profile, even without archived items. You may want to set visibility to 'Unlisted' until archived items have been added.",
+ )
+ : html`
+ ${msg(
+ "This collection will be visible on the org profile page, which isn't public yet. To make the org profile and this collection visible to the public, update org profile settings.",
+ )}
+
+ ${msg("Open org settings")}
+
+
+ `}
+
`,
)}
-
+
`;
}
@@ -160,25 +206,23 @@ export class CollectionMetadataDialog extends BtrixElement {
const form = event.target as HTMLFormElement;
const nameInput = form.querySelector('sl-input[name="name"]');
- if (
- !nameInput?.checkValidity() ||
- !this.descriptionEditor?.checkValidity()
- ) {
+
+ if (!nameInput?.checkValidity()) {
return;
}
- const { name } = serialize(form);
- const description = this.descriptionEditor.value;
+ const { name, caption } = serialize(form);
this.isSubmitting = true;
try {
const body = JSON.stringify({
name,
- description,
+ caption,
access:
this.selectCollectionAccess?.value ||
this.collection?.access ||
CollectionAccess.Private,
+ defaultThumbnailName: DEFAULT_THUMBNAIL,
});
let path = `/orgs/${this.orgId}/collections`;
let method = "POST";
@@ -199,9 +243,9 @@ export class CollectionMetadataDialog extends BtrixElement {
}) as CollectionSavedEvent,
);
this.notify.toast({
- message: msg(
- str`Successfully saved "${data.name || name}" Collection.`,
- ),
+ message: this.collection
+ ? msg(str`"${data.name || name}" metadata updated`)
+ : msg(str`Created "${data.name || name}" collection`),
variant: "success",
icon: "check2-circle",
id: "collection-metadata-status",
@@ -222,11 +266,4 @@ export class CollectionMetadataDialog extends BtrixElement {
this.isSubmitting = false;
}
-
- /**
- * https://github.com/shoelace-style/shoelace/issues/170
- */
- private stopProp(e: CustomEvent) {
- e.stopPropagation();
- }
}
diff --git a/frontend/src/features/collections/collection-replay-dialog.ts b/frontend/src/features/collections/collection-replay-dialog.ts
new file mode 100644
index 0000000000..41678a5176
--- /dev/null
+++ b/frontend/src/features/collections/collection-replay-dialog.ts
@@ -0,0 +1,393 @@
+import { localized, msg } from "@lit/localize";
+import type { SlChangeEvent, SlIcon, SlSelect } from "@shoelace-style/shoelace";
+import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
+import { html, nothing, type PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { when } from "lit/directives/when.js";
+import queryString from "query-string";
+
+import type { SelectSnapshotDetail } from "./select-collection-start-page";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Dialog } from "@/components/ui/dialog";
+import { formatRwpTimestamp } from "@/utils/replay";
+
+enum HomeView {
+ Pages = "pages",
+ URL = "url",
+}
+
+@localized()
+@customElement("btrix-collection-replay-dialog")
+export class CollectionStartPageDialog extends BtrixElement {
+ static readonly Options: Record<
+ HomeView,
+ { label: string; icon: NonNullable; detail: string }
+ > = {
+ [HomeView.Pages]: {
+ label: msg("Default"),
+ icon: "list-ul",
+ detail: `${msg("ReplayWeb.Page default view")}`,
+ },
+ [HomeView.URL]: {
+ label: msg("Page"),
+ icon: "file-earmark",
+ detail: msg("Load a single page URL"),
+ },
+ };
+
+ @property({ type: String })
+ collectionId?: string;
+
+ @property({ type: String })
+ homeUrl?: string | null = null;
+
+ @property({ type: String })
+ homePageId?: string | null = null;
+
+ @property({ type: String })
+ homeTs?: string | null = null;
+
+ @property({ type: Boolean })
+ open = false;
+
+ @property({ type: Boolean })
+ replayLoaded = false;
+
+ @state()
+ homeView = HomeView.Pages;
+
+ @state()
+ private showContent = false;
+
+ @state()
+ private isSubmitting = false;
+
+ @state()
+ private selectedSnapshot?: SelectSnapshotDetail["item"];
+
+ @query("btrix-dialog")
+ private readonly dialog?: Dialog | null;
+
+ @query("form")
+ private readonly form?: HTMLFormElement | null;
+
+ @query("#thumbnailPreview")
+ private readonly thumbnailPreview?: HTMLIFrameElement | null;
+
+ willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has("homeUrl") && this.homeUrl) {
+ this.homeView = HomeView.URL;
+ }
+ }
+
+ render() {
+ return html`
+ (this.showContent = true)}
+ @sl-after-hide=${() => (this.showContent = false)}
+ >
+ ${this.showContent ? this.renderContent() : nothing}
+
+ void this.dialog?.hide()}
+ >${msg("Cancel")}
+ {
+ this.form?.requestSubmit();
+ }}
+ >
+ ${msg("Save")}
+
+
+
+ `;
+ }
+
+ private renderContent() {
+ return html`
+
+
+
+ ${this.renderPreview()}
+
+
${this.renderForm()}
+
+ `;
+ }
+
+ private renderPreview() {
+ let urlPreview = html`
+
+ ${msg("Enter a Page URL to preview it")}
+
+ `;
+ const snapshot =
+ this.selectedSnapshot ||
+ (this.homeUrl
+ ? {
+ url: this.homeUrl,
+ ts: this.homeTs,
+ pageId: this.homePageId,
+ }
+ : null);
+
+ if (snapshot) {
+ urlPreview = html`
+
+ `;
+ }
+
+ return html`
+
+ ${when(
+ this.homeView === HomeView.URL && this.replayLoaded,
+ () => urlPreview,
+ )}
+
+ ${this.renderReplay()}
+
+
+ ${when(
+ !this.replayLoaded,
+ () => html`
+
+
+
+ `,
+ )}
+
+ `;
+ }
+
+ private renderForm() {
+ const { icon, detail } = CollectionStartPageDialog.Options[this.homeView];
+
+ return html`
+
+ `;
+ }
+
+ private renderReplay() {
+ const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
+ // TODO Get query from replay-web-page embed
+ const query = queryString.stringify({
+ source: replaySource,
+ customColl: this.collectionId,
+ embed: "default",
+ noCache: 1,
+ noSandbox: 1,
+ });
+
+ return html``;
+ }
+
+ private async onSubmit(e: SubmitEvent) {
+ e.preventDefault();
+
+ const form = e.currentTarget as HTMLFormElement;
+ const { homeView, useThumbnail } = serialize(form);
+
+ this.isSubmitting = true;
+
+ try {
+ await this.updateUrl({
+ pageId:
+ (homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null,
+ });
+
+ const shouldUpload =
+ homeView === HomeView.URL &&
+ useThumbnail === "on" &&
+ this.selectedSnapshot &&
+ this.homePageId !== this.selectedSnapshot.pageId;
+ // TODO get filename from rwp?
+ const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`;
+ let file: File | undefined;
+
+ if (shouldUpload && this.thumbnailPreview?.src) {
+ const { src } = this.thumbnailPreview;
+
+ // Wait to get the thumbnail image before closing the dialog
+ try {
+ const resp = await this.thumbnailPreview.contentWindow!.fetch(src);
+ const blob = await resp.blob();
+
+ file = new File([blob], fileName, {
+ type: blob.type,
+ });
+ } catch (err) {
+ console.debug(err);
+ }
+ } else {
+ this.notify.toast({
+ message: msg("Home view updated."),
+ variant: "success",
+ icon: "check2-circle",
+ id: "home-view-update-status",
+ });
+ }
+
+ this.isSubmitting = false;
+ this.open = false;
+
+ if (shouldUpload) {
+ try {
+ if (!file || !fileName) throw new Error("file or fileName missing");
+ await this.api.upload(
+ `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?filename=${fileName}`,
+ file,
+ );
+ await this.updateThumbnail({ defaultThumbnailName: null });
+
+ this.notify.toast({
+ message: msg("Home view and collection thumbnail updated."),
+ variant: "success",
+ icon: "check2-circle",
+ id: "home-view-update-status",
+ });
+ } catch (err) {
+ console.debug(err);
+
+ this.notify.toast({
+ message: msg(
+ "Home view updated, but couldn't update collection thumbnail at this time.",
+ ),
+ variant: "warning",
+ icon: "exclamation-triangle",
+ id: "home-view-update-status",
+ });
+ }
+ }
+ } catch (err) {
+ console.debug(err);
+
+ this.isSubmitting = false;
+
+ this.notify.toast({
+ message: msg("Sorry, couldn't update home view at this time."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ id: "home-view-update-status",
+ });
+ }
+ }
+
+ private async updateThumbnail({
+ defaultThumbnailName,
+ }: {
+ defaultThumbnailName: string | null;
+ }) {
+ return this.api.fetch<{ updated: boolean }>(
+ `/orgs/${this.orgId}/collections/${this.collectionId}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ defaultThumbnailName }),
+ },
+ );
+ }
+
+ private async updateUrl({ pageId }: { pageId: string | null }) {
+ return this.api.fetch(
+ `/orgs/${this.orgId}/collections/${this.collectionId}/home-url`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ pageId,
+ }),
+ },
+ );
+ }
+}
diff --git a/frontend/src/features/collections/collection-thumbnail.ts b/frontend/src/features/collections/collection-thumbnail.ts
new file mode 100644
index 0000000000..daebfbc3b6
--- /dev/null
+++ b/frontend/src/features/collections/collection-thumbnail.ts
@@ -0,0 +1,52 @@
+import { localized } from "@lit/localize";
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import thumbnailCyanSrc from "~assets/images/thumbnails/thumbnail-cyan.avif";
+import thumbnailGreenSrc from "~assets/images/thumbnails/thumbnail-green.avif";
+import thumbnailOrangeSrc from "~assets/images/thumbnails/thumbnail-orange.avif";
+import thumbnailYellowSrc from "~assets/images/thumbnails/thumbnail-yellow.avif";
+
+export enum Thumbnail {
+ Cyan = "thumbnail-cyan",
+ Green = "thumbnail-green",
+ Orange = "thumbnail-orange",
+ Yellow = "thumbnail-yellow",
+}
+
+export const DEFAULT_THUMBNAIL = Thumbnail.Cyan;
+
+@localized()
+@customElement("btrix-collection-thumbnail")
+export class CollectionThumbnail extends BtrixElement {
+ static readonly Variants: Record = {
+ [Thumbnail.Cyan]: {
+ path: thumbnailCyanSrc,
+ },
+ [Thumbnail.Green]: {
+ path: thumbnailGreenSrc,
+ },
+ [Thumbnail.Orange]: {
+ path: thumbnailOrangeSrc,
+ },
+ [Thumbnail.Yellow]: {
+ path: thumbnailYellowSrc,
+ },
+ };
+
+ @property({ type: String })
+ src?: string;
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+export const DEFAULT_THUMBNAIL_VARIANT =
+ CollectionThumbnail.Variants[DEFAULT_THUMBNAIL];
diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts
new file mode 100644
index 0000000000..579997f2e5
--- /dev/null
+++ b/frontend/src/features/collections/collections-grid.ts
@@ -0,0 +1,140 @@
+import { localized, msg } from "@lit/localize";
+import { html, nothing } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { when } from "lit/directives/when.js";
+
+import { CollectionThumbnail } from "./collection-thumbnail";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import { RouteNamespace } from "@/routes";
+import type { PublicCollection } from "@/types/collection";
+import { tw } from "@/utils/tailwind";
+
+/**
+ * Grid view of collections list
+ *
+ * @TODO Generalize into collections, just handling public collections for now
+ */
+@localized()
+@customElement("btrix-collections-grid")
+export class CollectionsGrid extends BtrixElement {
+ @property({ type: String })
+ slug = "";
+
+ @property({ type: Array })
+ collections?: PublicCollection[];
+
+ render() {
+ const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`;
+
+ if (!this.collections || !this.slug) {
+ const thumb = html`
+
+ `;
+
+ return html`
+ ${thumb}${thumb}${thumb}${thumb}
+ `;
+ }
+
+ if (!this.collections.length) {
+ return html`
+
+
+ ${msg("No public collections yet.")}
+
+
+
+ `;
+ }
+
+ const showActions = !this.navigate.isPublicPage && this.appState.isCrawler;
+
+ return html`
+
+ `;
+ }
+
+ private readonly renderActions = (collection: PublicCollection) => html`
+
+
+
+
+
+
+ ${msg("Visit Public Page")}
+
+
+
+
+
+ `;
+
+ private renderDateBadge(collection: PublicCollection) {
+ if (!collection.dateEarliest || !collection.dateLatest) return;
+
+ const earliestYear = this.localize.date(collection.dateEarliest, {
+ year: "numeric",
+ });
+ const latestYear = this.localize.date(collection.dateLatest, {
+ year: "numeric",
+ });
+
+ return html`
+
+ ${earliestYear}
+ ${latestYear !== earliestYear ? html` - ${latestYear} ` : nothing}
+
+ `;
+ }
+}
diff --git a/frontend/src/features/collections/index.ts b/frontend/src/features/collections/index.ts
index 949a13dbc5..3480cdd694 100644
--- a/frontend/src/features/collections/index.ts
+++ b/frontend/src/features/collections/index.ts
@@ -1,6 +1,10 @@
import("./collections-add");
+import("./collections-grid");
import("./collection-items-dialog");
import("./collection-metadata-dialog");
+import("./collection-replay-dialog");
import("./collection-workflow-list");
import("./select-collection-access");
+import("./select-collection-start-page");
import("./share-collection");
+import("./collection-thumbnail");
diff --git a/frontend/src/features/collections/select-collection-access.ts b/frontend/src/features/collections/select-collection-access.ts
index b94686c1d9..ce12d49292 100644
--- a/frontend/src/features/collections/select-collection-access.ts
+++ b/frontend/src/features/collections/select-collection-access.ts
@@ -2,6 +2,7 @@ import { localized, msg } from "@lit/localize";
import type { SlIcon, SlSelectEvent } from "@shoelace-style/shoelace";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
+import { when } from "lit/directives/when.js";
import { BtrixElement } from "@/classes/BtrixElement";
import { CollectionAccess } from "@/types/collection";
@@ -23,7 +24,6 @@ export class SelectCollectionAccess extends BtrixElement {
icon: "link-45deg",
detail: msg("Only people with the link can view"),
},
-
[CollectionAccess.Public]: {
label: msg("Public"),
icon: "globe2",
@@ -83,13 +83,40 @@ export class SelectCollectionAccess extends BtrixElement {
>
${label}
- ${detail}
+
+ ${detail}
+
`,
)}
+ ${when(
+ this.value === CollectionAccess.Public,
+ () => html`
+
+ `,
+ )}
`;
}
}
diff --git a/frontend/src/features/collections/select-collection-start-page.ts b/frontend/src/features/collections/select-collection-start-page.ts
new file mode 100644
index 0000000000..7b991e20d8
--- /dev/null
+++ b/frontend/src/features/collections/select-collection-start-page.ts
@@ -0,0 +1,308 @@
+import { localized, msg } from "@lit/localize";
+import { Task } from "@lit/task";
+import type {
+ SlChangeEvent,
+ SlInput,
+ SlSelect,
+} from "@shoelace-style/shoelace";
+import { html, type PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { when } from "lit/directives/when.js";
+import debounce from "lodash/fp/debounce";
+import sortBy from "lodash/fp/sortBy";
+import queryString from "query-string";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Combobox } from "@/components/ui/combobox";
+import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
+import type { UnderlyingFunction } from "@/types/utils";
+
+type Snapshot = {
+ pageId: string;
+ ts: string;
+ status: number;
+};
+
+type Page = {
+ url: string;
+ count: number;
+ snapshots: Snapshot[];
+};
+
+export type SelectSnapshotDetail = {
+ item: Snapshot & { url: string };
+};
+
+const DEFAULT_PROTOCOL = "http";
+
+const sortByTs = sortBy("ts");
+
+/**
+ * @fires btrix-select
+ */
+@localized()
+@customElement("btrix-select-collection-start-page")
+export class SelectCollectionStartPage extends BtrixElement {
+ @property({ type: String })
+ collectionId?: string;
+
+ @property({ type: String })
+ homeUrl?: string | null = null;
+
+ @property({ type: String })
+ homeTs?: string | null = null;
+
+ @state()
+ private searchQuery = "";
+
+ @state()
+ private selectedPage?: Page;
+
+ @state()
+ private selectedSnapshot?: Snapshot;
+
+ @query("btrix-combobox")
+ private readonly combobox?: Combobox | null;
+
+ @query("sl-input")
+ private readonly input?: SlInput | null;
+
+ public get page() {
+ return this.selectedPage;
+ }
+
+ public get snapshot() {
+ return this.selectedSnapshot;
+ }
+
+ updated(changedProperties: PropertyValues) {
+ if (changedProperties.has("homeUrl") && this.homeUrl) {
+ if (this.input) {
+ this.input.value = this.homeUrl;
+ }
+ this.searchQuery = this.homeUrl;
+ void this.initSelection();
+ }
+ }
+
+ private async initSelection() {
+ await this.updateComplete;
+ await this.searchResults.taskComplete;
+
+ if (this.homeUrl && this.searchResults.value) {
+ this.selectedPage = this.searchResults.value.items.find(
+ ({ url }) => url === this.homeUrl,
+ );
+
+ if (this.selectedPage && this.homeTs) {
+ this.selectedSnapshot = this.selectedPage.snapshots.find(
+ ({ ts }) => ts === this.homeTs,
+ );
+ }
+ }
+ }
+
+ private readonly searchResults = new Task(this, {
+ task: async ([searchValue], { signal }) => {
+ const searchResults = await this.getPageUrls(
+ {
+ id: this.collectionId!,
+ urlPrefix: searchValue,
+ },
+ signal,
+ );
+
+ return searchResults;
+ },
+ args: () => [this.searchQuery] as const,
+ });
+
+ render() {
+ return html`
+
+ ${this.renderPageSearch()}
+ {
+ const { value } = e.currentTarget as SlSelect;
+
+ await this.updateComplete;
+
+ this.selectedSnapshot = this.selectedPage?.snapshots.find(
+ ({ pageId }) => pageId === value,
+ );
+
+ if (this.selectedSnapshot) {
+ this.dispatchEvent(
+ new CustomEvent("btrix-select", {
+ detail: {
+ item: {
+ url: this.selectedPage!.url,
+ ...this.selectedSnapshot,
+ },
+ },
+ }),
+ );
+ }
+ }}
+ >
+ ${when(
+ this.selectedSnapshot,
+ (snapshot) => html`
+ ${snapshot.status}
+ `,
+ )}
+ ${when(this.selectedPage, (item) =>
+ item.snapshots.map(
+ ({ pageId, ts, status }) => html`
+
+ ${this.localize.date(ts)}
+ ${status}
+
+ `,
+ ),
+ )}
+
+
+ `;
+ }
+
+ private renderPageSearch() {
+ return html`
+ {
+ this.combobox?.hide();
+ }}
+ >
+ {
+ this.combobox?.show();
+ }}
+ @sl-clear=${async () => {
+ this.searchQuery = "";
+ }}
+ @sl-input=${this.onSearchInput as UnderlyingFunction<
+ typeof this.onSearchInput
+ >}
+ >
+
+
+ ${this.renderSearchResults()}
+
+ `;
+ }
+
+ private renderSearchResults() {
+ return this.searchResults.render({
+ pending: () => html`
+
+
+
+ `,
+ complete: ({ items }) => {
+ if (!items.length) {
+ return html`
+
+ ${msg("No matching pages found.")}
+
+ `;
+ }
+
+ return html`
+ ${items.map((item: Page) => {
+ return html`
+ {
+ if (this.input) {
+ this.input.value = item.url;
+ }
+
+ this.selectedPage = {
+ ...item,
+ // TODO check if backend can sort
+ snapshots: sortByTs(item.snapshots).reverse(),
+ };
+
+ this.combobox?.hide();
+
+ this.selectedSnapshot = this.selectedPage.snapshots[0];
+
+ await this.updateComplete;
+ this.dispatchEvent(
+ new CustomEvent("btrix-select", {
+ detail: {
+ item: {
+ url: this.selectedPage.url,
+ ...this.selectedSnapshot,
+ },
+ },
+ }),
+ );
+ }}
+ >${item.url}
+
+ `;
+ })}
+ `;
+ },
+ });
+ }
+
+ private readonly onSearchInput = debounce(400)(() => {
+ const value = this.input?.value;
+
+ if (!value) {
+ return;
+ }
+
+ if (value.startsWith(DEFAULT_PROTOCOL)) {
+ this.combobox?.show();
+ } else {
+ if (value !== DEFAULT_PROTOCOL.slice(0, value.length)) {
+ this.input.value = `https://${value}`;
+
+ this.combobox?.show();
+ }
+ }
+
+ this.searchQuery = this.input.value;
+ });
+
+ private async getPageUrls(
+ {
+ id,
+ urlPrefix,
+ page = 1,
+ pageSize = 5,
+ }: {
+ id: string;
+ urlPrefix?: string;
+ } & APIPaginationQuery,
+ signal?: AbortSignal,
+ ) {
+ const query = queryString.stringify({
+ page,
+ pageSize,
+ urlPrefix: urlPrefix ? window.encodeURIComponent(urlPrefix) : undefined,
+ });
+ return this.api.fetch>(
+ `/orgs/${this.orgId}/collections/${id}/urls?${query}`,
+ { signal },
+ );
+ }
+}
diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts
index 959f7096e2..d0fa0361f8 100644
--- a/frontend/src/features/collections/share-collection.ts
+++ b/frontend/src/features/collections/share-collection.ts
@@ -1,27 +1,45 @@
import { localized, msg, str } from "@lit/localize";
-import type { SlSelectEvent } from "@shoelace-style/shoelace";
-import { html } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
+import type {
+ SlChangeEvent,
+ SlSelectEvent,
+ SlSwitch,
+ SlTabGroup,
+} from "@shoelace-style/shoelace";
+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 {
+ CollectionThumbnail,
+ DEFAULT_THUMBNAIL_VARIANT,
+ Thumbnail,
+} from "./collection-thumbnail";
import { SelectCollectionAccess } from "./select-collection-access";
import { BtrixElement } from "@/classes/BtrixElement";
import { ClipboardController } from "@/controllers/clipboard";
import { RouteNamespace } from "@/routes";
-import { CollectionAccess, type Collection } from "@/types/collection";
+import {
+ CollectionAccess,
+ type Collection,
+ type PublicCollection,
+} from "@/types/collection";
-export type SelectVisibilityDetail = {
- item: { value: CollectionAccess };
-};
+enum Tab {
+ Link = "link",
+ Embed = "embed",
+}
/**
- * @fires btrix-select
+ * @fires btrix-change
*/
@localized()
@customElement("btrix-share-collection")
export class ShareCollection extends BtrixElement {
+ @property({ type: String })
+ slug = "";
+
@property({ type: String })
collectionId = "";
@@ -31,8 +49,8 @@ export class ShareCollection extends BtrixElement {
@state()
private showDialog = false;
- @state()
- private showEmbedCode = false;
+ @query("sl-tab-group")
+ private readonly tabGroup?: SlTabGroup | null;
private readonly clipboardController = new ClipboardController(this);
@@ -87,9 +105,7 @@ export class ShareCollection extends BtrixElement {
{
- this.showEmbedCode = true;
+ this.tabGroup?.show(Tab.Embed);
this.showDialog = true;
}}
>
@@ -127,14 +143,7 @@ export class ShareCollection extends BtrixElement {
${msg("View Embed Code")}
${when(
- this.authState &&
- this.collectionId &&
- this.shareLink !==
- window.location.href.slice(
- 0,
- window.location.href.indexOf(this.collectionId) +
- this.collectionId.length,
- ),
+ this.authState && !this.navigate.isPublicPage,
() => html`
-
- {
- this.showDialog = true;
- }}
- >
-
- ${msg("Change Link Visibility")}
-
- `,
- () => html`
-
-
- ${msg("Download Collection")}
-
+ ${this.appState.isCrawler
+ ? html`
+
+ {
+ this.showDialog = true;
+ }}
+ >
+
+ ${msg("Link Settings")}
+
+ `
+ : nothing}
`,
)}
+ ${when(this.slug && this.collection, (collection) =>
+ collection.access === CollectionAccess.Public &&
+ collection.allowPublicDownload
+ ? html`
+
+
+ ${msg("Download Collection")}
+ ${when(
+ this.collection,
+ (collection) => html`
+ ${this.localize.bytes(
+ collection.totalSize || 0,
+ )}
+ `,
+ )}
+
+ `
+ : nothing,
+ )}
@@ -183,6 +214,8 @@ export class ShareCollection extends BtrixElement {
}
private renderDialog() {
+ const showSettings = !this.navigate.isPublicPage && this.authState;
+
return html`
{
- this.showEmbedCode = false;
+ this.tabGroup?.show(Tab.Link);
}}
- style="--width: 32rem;"
+ class="[--body-spacing:0] [--width:40rem]"
>
- ${when(
- this.authState && this.collection,
- (collection) => html`
-
-
{
- this.dispatchEvent(
- new CustomEvent("btrix-select", {
- detail: {
- item: {
- value: (e.target as SelectCollectionAccess).value,
- },
- },
- }),
- );
- }}
- >
+
+ ${showSettings ? msg("Link Settings") : msg("Link")}
+ ${msg("Embed")}
+
+
+
+ ${when(
+ showSettings && this.collection,
+ this.renderSettings,
+ this.renderShareLink,
+ )}
- `,
- )}
- ${this.renderShareLink()} ${this.renderEmbedCode()}
-
+
+
+
+ ${this.renderEmbedCode()}
+
+
+
+
+ ${when(showSettings, this.renderShareLink)}
(this.showDialog = false)}>
${msg("Done")}
@@ -227,16 +259,145 @@ export class ShareCollection extends BtrixElement {
`;
}
+ private readonly renderSettings = (collection: Partial
) => {
+ return html`
+
+ {
+ void this.updateVisibility(
+ (e.target as SelectCollectionAccess).value,
+ );
+ }}
+ >
+ ${when(
+ this.org &&
+ !this.org.enablePublicProfile &&
+ this.collection?.access === CollectionAccess.Public,
+ () => html`
+
+ ${msg(
+ "The org profile page isn't public yet. To make the org profile and this collection visible to the public, update profile visibility in org settings.",
+ )}
+
+ `,
+ )}
+
+
+
+ ${msg("Thumbnail")}
+
+
+
+
+ ${this.renderThumbnails()}
+
+
+
+ ${msg("Downloads")}
+
+
+
+
+
+ {
+ void this.updateAllowDownload((e.target as SlSwitch).checked);
+ }}
+ >${msg("Show download button")}
+
+
+ `;
+ };
+
+ private renderThumbnails() {
+ let selectedImgSrc = DEFAULT_THUMBNAIL_VARIANT.path;
+
+ if (this.collection?.defaultThumbnailName) {
+ const { defaultThumbnailName } = this.collection;
+ const variant = Object.entries(CollectionThumbnail.Variants).find(
+ ([name]) => name === defaultThumbnailName,
+ );
+
+ if (variant) {
+ selectedImgSrc = variant[1].path;
+ }
+ } else if (this.collection?.thumbnail) {
+ selectedImgSrc = this.collection.thumbnail.path;
+ }
+
+ const thumbnail = (
+ thumbnail: Thumbnail | NonNullable,
+ ) => {
+ let name: Thumbnail | null = null;
+ let path = "";
+
+ if (Object.values(Thumbnail).some((t) => t === thumbnail)) {
+ name = thumbnail as Thumbnail;
+ path = CollectionThumbnail.Variants[name].path;
+ } else {
+ path = (thumbnail as NonNullable).path;
+ }
+
+ if (!path) {
+ console.debug("no path for thumbnail:", thumbnail);
+ return;
+ }
+
+ const isSelected = path === selectedImgSrc;
+
+ return html`
+
+
+
+ `;
+ };
+
+ return html`
+
+ ${when(this.collection?.thumbnail, (t) => thumbnail(t))}
+ ${thumbnail(Thumbnail.Cyan)} ${thumbnail(Thumbnail.Green)}
+ ${thumbnail(Thumbnail.Yellow)} ${thumbnail(Thumbnail.Orange)}
+
+ `;
+ }
+
private readonly renderShareLink = () => {
return html`
-
- ${msg("Link to Share")}
+
+
${msg("Link to Share")}
-
+
`;
};
@@ -261,64 +422,199 @@ export class ShareCollection extends BtrixElement {
const importCode = `importScripts("https://replayweb.page/sw.js");`;
return html`
-
- ${msg("Embed Code")}
- ${when(
- this.collection?.access === CollectionAccess.Private,
- () => html`
-
- ${msg("Change the visibility setting to embed this collection.")}
-
- `,
+ ${when(
+ this.collection?.access === CollectionAccess.Private,
+ () => html`
+
+ ${msg("Change the visibility setting to embed this collection.")}
+
+ `,
+ )}
+
+ ${msg(
+ html`To embed this collection into an existing webpage, add the
+ following embed code:`,
)}
-
- ${msg(
- html`To embed this collection into an existing webpage, add the
- following embed code:`,
- )}
-
-
-
-
- embedCode}
- content=${msg("Copy Embed Code")}
- hoist
- raised
- >
-
+
+
+
+
+ embedCode}
+ content=${msg("Copy Embed Code")}
+ hoist
+ raised
+ >
-
- ${msg(
- html`Add the following JavaScript to your
- /replay/sw.js
:`,
- )}
-
-
-
-
- importCode}
- content=${msg("Copy JS")}
- hoist
- raised
- >
-
+
+
+ ${msg(
+ html`Add the following JavaScript to your
+ /replay/sw.js
:`,
+ )}
+
+
+
+
+ importCode}
+ content=${msg("Copy JS")}
+ hoist
+ raised
+ >
-
- ${msg(
- html`See
-
- our embedding guide
- for more details.`,
- )}
-
-
+
+
+ ${msg(
+ html`See
+
+ our embedding guide
+ for more details.`,
+ )}
+
`;
};
+
+ private async updateVisibility(access: CollectionAccess) {
+ const prevValue = this.collection?.access;
+
+ // Optimistic update
+ if (this.collection) {
+ this.collection = { ...this.collection, access };
+ }
+
+ try {
+ await this.api.fetch<{ updated: boolean }>(
+ `/orgs/${this.orgId}/collections/${this.collectionId}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ access }),
+ },
+ );
+
+ this.dispatchEvent(new CustomEvent("btrix-change"));
+
+ this.notify.toast({
+ id: "collection-visibility-update-status",
+ message: msg("Collection visibility updated."),
+ variant: "success",
+ icon: "check2-circle",
+ });
+ } catch (err) {
+ console.debug(err);
+
+ // Revert optimistic update
+ if (this.collection && prevValue !== undefined) {
+ this.collection = { ...this.collection, access: prevValue };
+ }
+
+ this.notify.toast({
+ id: "collection-visibility-update-status",
+ message: msg("Sorry, couldn't update visibility at this time."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ });
+ }
+ }
+
+ async updateThumbnail({
+ defaultThumbnailName,
+ }: {
+ defaultThumbnailName: Thumbnail | null;
+ }) {
+ const prevValue = this.collection?.defaultThumbnailName;
+
+ // Optimistic update
+ if (this.collection) {
+ this.collection = { ...this.collection, defaultThumbnailName };
+ }
+
+ try {
+ await this.api.fetch<{ updated: boolean }>(
+ `/orgs/${this.orgId}/collections/${this.collectionId}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ defaultThumbnailName }),
+ },
+ );
+
+ this.dispatchEvent(new CustomEvent("btrix-change"));
+
+ this.notify.toast({
+ id: "collection-thumbnail-update-status",
+ message: msg("Thumbnail updated."),
+ variant: "success",
+ icon: "check2-circle",
+ });
+ } catch (err) {
+ console.debug(err);
+
+ // Revert optimistic update
+ if (this.collection && prevValue !== undefined) {
+ this.collection = {
+ ...this.collection,
+ defaultThumbnailName: prevValue,
+ };
+ }
+
+ this.notify.toast({
+ id: "collection-thumbnail-update-status",
+ message: msg("Sorry, couldn't update thumbnail at this time."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ });
+ }
+ }
+
+ async updateAllowDownload(allowPublicDownload: boolean) {
+ const prevValue = this.collection?.allowPublicDownload;
+
+ // Optimistic update
+ if (this.collection) {
+ this.collection = { ...this.collection, allowPublicDownload };
+ }
+
+ try {
+ await this.api.fetch<{ updated: boolean }>(
+ `/orgs/${this.orgId}/collections/${this.collectionId}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ allowPublicDownload }),
+ },
+ );
+
+ this.dispatchEvent(new CustomEvent("btrix-change"));
+
+ this.notify.toast({
+ id: "collection-allow-public-download-update-status",
+ message: allowPublicDownload
+ ? msg("Download button enabled.")
+ : msg("Download button hidden."),
+ variant: "success",
+ icon: "check2-circle",
+ });
+ } catch (err) {
+ console.debug(err);
+
+ // Revert optimistic update
+ if (this.collection && prevValue !== undefined) {
+ this.collection = {
+ ...this.collection,
+ allowPublicDownload: prevValue,
+ };
+ }
+
+ this.notify.toast({
+ id: "collection-allow-public-download-update-status",
+ message: msg("Sorry, couldn't update download button at this time."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ });
+ }
+ }
}
diff --git a/frontend/src/layouts/page.ts b/frontend/src/layouts/page.ts
index adbc4c1217..652d220f44 100644
--- a/frontend/src/layouts/page.ts
+++ b/frontend/src/layouts/page.ts
@@ -1,52 +1,93 @@
import clsx from "clsx";
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
+import { html as staticHtml, unsafeStatic } from "lit/static-html.js";
-import { pageTitle } from "./pageHeader";
+import { pageNav, pageTitle } from "./pageHeader";
-import type { tw } from "@/utils/tailwind";
+import { tw } from "@/utils/tailwind";
type Content = string | TemplateResult | typeof nothing;
+export function pageHeading({
+ content,
+ level = 2,
+}: {
+ content?: string | TemplateResult | typeof nothing;
+ level?: 2 | 3 | 4;
+}) {
+ const tag = unsafeStatic(`h${level}`);
+ const classNames = tw`min-w-0 text-lg font-medium leading-8`;
+
+ return staticHtml`
+ <${tag} class=${classNames}>
+ ${
+ content ||
+ html`
`
+ }
+ ${tag}>
+ `;
+}
+
// TODO consolidate with pageHeader.ts https://github.com/webrecorder/browsertrix/issues/2197
-function pageHeader({
+export function pageHeader({
title,
+ prefix,
suffix,
secondary,
actions,
+ border = true,
classNames,
}: {
- title: Content;
+ title?: Content;
+ prefix?: Content;
suffix?: Content;
secondary?: Content;
actions?: Content;
+ border?: boolean;
classNames?: typeof tw;
}) {
return html`
-