diff --git a/frontend/index.d.ts b/frontend/index.d.ts index 57f96d4072..a711dad908 100644 --- a/frontend/index.d.ts +++ b/frontend/index.d.ts @@ -1,3 +1,4 @@ +declare module "*.avif"; declare module "*.svg"; declare module "*.webp"; declare module "*.css"; diff --git a/frontend/src/assets/images/thumbnails/thumbnail-cyan.avif b/frontend/src/assets/images/thumbnails/thumbnail-cyan.avif new file mode 100644 index 0000000000..05092899a4 Binary files /dev/null and b/frontend/src/assets/images/thumbnails/thumbnail-cyan.avif differ diff --git a/frontend/src/assets/images/thumbnails/thumbnail-green.avif b/frontend/src/assets/images/thumbnails/thumbnail-green.avif new file mode 100644 index 0000000000..d2e8ba7567 Binary files /dev/null and b/frontend/src/assets/images/thumbnails/thumbnail-green.avif differ diff --git a/frontend/src/assets/images/thumbnails/thumbnail-orange.avif b/frontend/src/assets/images/thumbnails/thumbnail-orange.avif new file mode 100644 index 0000000000..4f43f820d1 Binary files /dev/null and b/frontend/src/assets/images/thumbnails/thumbnail-orange.avif differ diff --git a/frontend/src/assets/images/thumbnails/thumbnail-yellow.avif b/frontend/src/assets/images/thumbnails/thumbnail-yellow.avif new file mode 100644 index 0000000000..40730cecd4 Binary files /dev/null and b/frontend/src/assets/images/thumbnails/thumbnail-yellow.avif differ diff --git a/frontend/src/components/not-found.ts b/frontend/src/components/not-found.ts index 7f64e8904e..3d346e62a6 100644 --- a/frontend/src/components/not-found.ts +++ b/frontend/src/components/not-found.ts @@ -1,5 +1,5 @@ import { localized, msg } from "@lit/localize"; -import { html } from "lit"; +import { html, nothing } from "lit"; import { customElement } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; @@ -9,27 +9,39 @@ import { BtrixElement } from "@/classes/BtrixElement"; export class NotFound extends BtrixElement { render() { return html` -
-

- ${msg("Page not found")} +

+

+ ${msg("Sorry, we couldn’t find that page")}

-

+

+ ${msg("Check the URL to make sure you’ve entered it correctly.")} +

+
+ ${msg("Go to Home")} +
+

${msg("Did you click a link to get here?")} -
- ${msg("Or")} - - ${msg("Report a Broken Link")} - + ${this.navigate.isPublicPage + ? nothing + : html` +
+ ${msg("Or")} + + ${msg("Report a Broken Link")} + + `}

`; diff --git a/frontend/src/components/ui/button.ts b/frontend/src/components/ui/button.ts index 5ef6a2baf3..f06bd3d4e5 100644 --- a/frontend/src/components/ui/button.ts +++ b/frontend/src/components/ui/button.ts @@ -74,7 +74,7 @@ export class Button extends TailwindElement { small: tw`min-h-6 min-w-6 rounded-md text-base`, medium: tw`min-h-8 min-w-8 rounded-sm text-lg`, }[this.size], - this.raised && tw`border shadow-sm`, + this.raised && tw`shadow ring-1 ring-neutral-200`, this.filled ? [ tw`text-white`, diff --git a/frontend/src/components/ui/markdown-editor.ts b/frontend/src/components/ui/markdown-editor.ts index 5359378b2e..056d868cb1 100644 --- a/frontend/src/components/ui/markdown-editor.ts +++ b/frontend/src/components/ui/markdown-editor.ts @@ -1,4 +1,4 @@ -import { msg, str } from "@lit/localize"; +import { localized, msg, str } from "@lit/localize"; import { wrap, type AwaitableInstance } from "ink-mde"; import { css, html, type PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators.js"; @@ -16,6 +16,7 @@ export type MarkdownChangeEvent = CustomEvent; * * @fires btrix-change MarkdownChangeEvent */ +@localized() @customElement("btrix-markdown-editor") export class MarkdownEditor extends BtrixElement { static styles = css` @@ -53,11 +54,18 @@ export class MarkdownEditor extends BtrixElement { white-space: nowrap; border-width: 0; } + + .cm-line:only-child { + min-height: 8em; + } `; @property({ type: String }) label = ""; + @property({ type: String }) + placeholder = ""; + @property({ type: String }) initialValue = ""; @@ -76,6 +84,11 @@ export class MarkdownEditor extends BtrixElement { return this.textarea?.checkValidity(); } + public async focus() { + await this.updateComplete; + (await this.editor)?.focus(); + } + protected willUpdate(changedProperties: PropertyValues): void { if ( changedProperties.has("initialValue") && @@ -99,7 +112,7 @@ export class MarkdownEditor extends BtrixElement { const isInvalid = this.maxlength && this.value.length > this.maxlength; return html`
- + ${this.label && html``}

@@ -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` +
+
+

${msg("Preview")}

+ ${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` +
+ { + this.homeView = (e.currentTarget as SlSelect).value as HomeView; + }} + > + ${this.replayLoaded + ? html`` + : html``} + + ${detail} + + ${Object.values(HomeView).map((homeView) => { + const { label, icon, detail } = + CollectionStartPageDialog.Options[homeView]; + return html` + + + ${label} + ${detail} + + `; + })} + + + ${when( + this.homeView === HomeView.URL, + () => html` + +
+ , + ) => { + this.selectedSnapshot = e.detail.item; + }} + > + + + ${msg("Update collection thumbnail")} + + + + +
+ `, + )} +
+ `; + } + + 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` + +
+ + ${msg("What information will be visible to the public?")} +
+ ${msg( + "In addition to replay, the following collection details will be visible:", + )} +
    +
  • ${msg("Name")}
  • +
  • ${msg("Summary")}
  • +
  • ${msg("About")}
  • +
  • ${msg("Collection Period")}
  • +
  • ${msg("Total Pages")}
  • +
  • ${msg("Collection Size")}
  • +
+
+ `, + )} `; } } 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`` + } + + `; +} + // 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` -
-
-
${pageTitle(title)} ${suffix}
- ${actions - ? html`
${actions}
` - : nothing} +
+
+
+ ${prefix}${pageTitle(title)}${suffix} +
+ ${secondary}
- ${secondary} + + ${actions + ? html`
+ ${actions} +
` + : nothing}
`; } export function page( - header: Parameters[0], + header: Parameters[0] & { + breadcrumbs?: Parameters[0]; + }, render: () => TemplateResult, ) { return html`
+ ${header.breadcrumbs ? html` ${pageNav(header.breadcrumbs)} ` : nothing} ${pageHeader(header)} -
${render()}
+
${render()}
`; } diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index f405a6d919..b8678eea7d 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -87,7 +87,8 @@ export function pageBack({ href, content }: Breadcrumb) { export function pageTitle(title?: string | TemplateResult | typeof nothing) { return html`

- ${title || html``} + ${title || + html``}

`; } diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts index ab1fdcb370..1150fb2fba 100644 --- a/frontend/src/pages/collections/collection.ts +++ b/frontend/src/pages/collections/collection.ts @@ -1,15 +1,16 @@ -import { localized, msg } from "@lit/localize"; +import { localized, msg, str } from "@lit/localize"; import { Task } from "@lit/task"; -import { html, nothing } from "lit"; +import { html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { choose } from "lit/directives/choose.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { SelectVisibilityDetail } from "@/features/collections/share-collection"; import { page } from "@/layouts/page"; import { RouteNamespace } from "@/routes"; -import type { OrgProfileData, PublicCollection } from "@/types/org"; +import type { PublicCollection } from "@/types/collection"; +import type { PublicOrgCollections } from "@/types/org"; +import { formatRwpTimestamp } from "@/utils/replay"; enum Tab { Replay = "replay", @@ -28,90 +29,139 @@ export class Collection extends BtrixElement { @property({ type: String }) tab: Tab | string = Tab.Replay; + get canEditCollection() { + return this.slug === this.orgSlug && this.appState.isCrawler; + } + private readonly tabLabels: Record< Tab, { icon: { name: string; library: string }; text: string } > = { [Tab.Replay]: { icon: { name: "replaywebpage", library: "app" }, - text: msg("Replay"), + text: msg("Browse Collection"), }, [Tab.About]: { icon: { name: "info-square-fill", library: "default" }, - text: msg("About"), + text: msg("About This Collection"), }, }; - readonly publicOrg = new Task(this, { + private readonly orgCollections = new Task(this, { task: async ([slug]) => { - if (!slug) return; - const org = await this.fetchOrgProfile(slug); + if (!slug) throw new Error("slug required"); + const org = await this.fetchCollections({ slug }); return org; }, + args: () => [this.slug] as const, + }); + + private readonly collection = new Task(this, { + task: async ([slug, collectionId]) => { + if (!slug || !collectionId) + throw new Error("slug and collection required"); + const collection = await this.fetchCollection({ slug, collectionId }); + + if (!collection.crawlCount && (this.tab as unknown) === Tab.Replay) { + this.tab = Tab.About; + } + + return collection; + }, args: () => [this.slug, this.collectionId] as const, }); render() { - return html` - ${this.publicOrg.render({ - complete: (profile) => - profile ? this.renderCollection(profile) : nothing, - })} - `; + return this.collection.render({ + complete: this.renderComplete, + error: this.renderError, + }); } - private renderCollection({ org, collections }: OrgProfileData) { - const collection = - this.collectionId && - collections.find(({ id }) => id === this.collectionId); + private readonly renderComplete = (collection: PublicCollection) => { + const org = this.orgCollections.value?.org; + const header: Parameters[0] = { + breadcrumbs: org + ? [ + { + href: `/${RouteNamespace.PublicOrgs}/${this.slug}`, + content: org.name, + }, + { + href: `/${RouteNamespace.PublicOrgs}/${this.slug}`, + content: msg("Collections"), + }, + { + content: collection.name, + }, + ] + : [], + title: collection.name || "", + actions: html` + ${when( + this.canEditCollection, + () => html` + + + + `, + )} + + + `, + }; - if (!collection) { - return "TODO"; + if (collection.caption) { + header.secondary = html` +
${collection.caption}
+ `; } + const panel = (tab: Tab, content: TemplateResult) => html` +
+ ${content} +
+ `; + return html` ${page( - { - title: collection.name, - secondary: html` -
- ${msg("Collection by")} - ${org.name} -
- `, - actions: html` - ) => { - e.stopPropagation(); - console.log("TODO"); - }} - > - `, - }, + header, () => html` - ${choose( - this.tab, - [ - [Tab.Replay, () => this.renderReplay(collection)], - [Tab.About, () => this.renderAbout(collection)], - ], - () => html``, + ${when(collection.crawlCount, () => + panel(Tab.Replay, this.renderReplay(collection)), )} + ${panel(Tab.About, this.renderAbout(collection))} `, )} `; - } + }; + + private readonly renderError = (error?: unknown) => { + console.log("error", error); + + return html`
+ +
`; + }; private readonly renderTab = (tab: Tab) => { const isSelected = tab === (this.tab as Tab); @@ -139,9 +189,15 @@ export class Collection extends BtrixElement { ).href; return html` -
+
-
-

- ${msg("Description")} -

-
- ${collection.description - ? html` - - ` - : html`

- ${msg( - "A description has not been provided for this collection.", - )} -

`} -
-
-
-

- ${msg("Metadata")} -

- - - ${this.localize.number(collection.crawlCount)} - - - ${this.localize.number(collection.pageCount)} - - - ${this.localize.bytes(collection.totalSize)} - - - TODO - - -
-
+ const dateRange = () => { + if (!collection.dateEarliest || !collection.dateLatest) { + return msg("n/a"); + } + const format: Intl.DateTimeFormatOptions = { + month: "long", + year: "numeric", + }; + const dateEarliest = this.localize.date(collection.dateEarliest, format); + const dateLatest = this.localize.date(collection.dateLatest, format); + + if (dateEarliest === dateLatest) return dateLatest; + + return msg(str`${dateEarliest} to ${dateLatest}`, { + desc: "Date range formatted to show full month name and year", + }); + }; + + const metadata = html` + + + ${dateRange()} + + + ${this.localize.number(collection.pageCount)} + + + ${this.localize.bytes(collection.totalSize)} + + `; + + if (collection.description) { + return html` +
+
+ +
+
+ +

${msg("Metadata")}

+
+
${metadata}
+
+
+ `; + } + + return html`
${metadata}
`; } - private async fetchOrgProfile(slug: string): Promise { - const resp = await fetch(`/api/public-collections/${slug}`, { + private async fetchCollections({ + slug, + }: { + slug: string; + }): Promise { + const resp = await fetch(`/api/public/orgs/${slug}/collections`, { headers: { "Content-Type": "application/json" }, }); switch (resp.status) { case 200: - return (await resp.json()) as OrgProfileData; - case 404: { + return (await resp.json()) as PublicOrgCollections; + default: throw resp.status; - } + } + } + + private async fetchCollection({ + slug, + collectionId, + }: { + slug: string; + collectionId: string; + }): Promise { + const resp = await fetch( + `/api/public/orgs/${slug}/collections/${collectionId}`, + { + headers: { "Content-Type": "application/json" }, + }, + ); + + switch (resp.status) { + case 200: + return (await resp.json()) as PublicCollection; default: throw resp.status; } diff --git a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts index 63b371cefd..4388e88e33 100644 --- a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts +++ b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts @@ -45,6 +45,7 @@ import { type finishedCrawlStates, } from "@/utils/crawler"; import { maxLengthValidator } from "@/utils/form"; +import { formatRwpTimestamp } from "@/utils/replay"; import { tw } from "@/utils/tailwind"; const DEFAULT_PAGE_SIZE = 100; @@ -1314,7 +1315,7 @@ export class ArchivedItemQA extends BtrixElement { return; } - const timestamp = page.ts?.split(".")[0].replace(/\D/g, ""); + const timestamp = formatRwpTimestamp(page.ts) || ""; const pageUrl = page.url; const doLoad = async (isQA: boolean) => { diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index a0822556e1..53871ee775 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -8,12 +8,11 @@ import { when } from "lit/directives/when.js"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; +import type { MarkdownEditor } from "@/components/ui/markdown-editor"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; -import type { - SelectVisibilityDetail, - ShareCollection, -} from "@/features/collections/share-collection"; +import type { ShareCollection } from "@/features/collections/share-collection"; +import { pageHeader } from "@/layouts/page"; import { pageNav, type Breadcrumb } from "@/layouts/pageHeader"; import type { APIPaginatedList, @@ -24,12 +23,16 @@ import { CollectionAccess, type Collection } from "@/types/collection"; import type { ArchivedItem, Crawl, Upload } from "@/types/crawler"; import type { CrawlState } from "@/types/crawlState"; import { pluralOf } from "@/utils/pluralize"; +import { formatRwpTimestamp } from "@/utils/replay"; 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]; + +export enum Tab { + Replay = "replay", + About = "about", + Items = "items", +} @localized() @customElement("btrix-collection-detail") @@ -38,7 +41,7 @@ export class CollectionDetail extends BtrixElement { collectionId!: string; @property({ type: String }) - collectionTab?: Tab = TABS[0]; + collectionTab: Tab = Tab.Replay; @state() private collection?: Collection; @@ -47,16 +50,17 @@ export class CollectionDetail extends BtrixElement { private archivedItems?: APIPaginatedList; @state() - private openDialogName?: "delete" | "editMetadata" | "editItems"; + private openDialogName?: + | "delete" + | "editMetadata" + | "editItems" + | "editStartPage"; @state() - private isDescriptionExpanded = false; + private isEditingDescription = false; - @query(".description") - private readonly description?: HTMLElement | null; - - @query(".descriptionExpandBtn") - private readonly descriptionExpandBtn?: HTMLElement | null; + @state() + private isRwpLoaded = false; @query("replay-web-page") private readonly replayEmbed?: ReplayWebPage | null; @@ -64,6 +68,9 @@ export class CollectionDetail extends BtrixElement { @query("btrix-share-collection") private readonly shareCollection?: ShareCollection | null; + @query("btrix-markdown-editor") + private readonly descriptionEditor?: MarkdownEditor | null; + // Use to cancel requests private getArchivedItemsController: AbortController | null = null; @@ -71,14 +78,18 @@ export class CollectionDetail extends BtrixElement { Tab, { icon: { name: string; library: string }; text: string } > = { - replay: { + [Tab.Replay]: { icon: { name: "replaywebpage", library: "app" }, text: msg("Replay"), }, - items: { + [Tab.Items]: { icon: { name: "list-ul", library: "default" }, text: msg("Archived Items"), }, + [Tab.About]: { + icon: { name: "info-square-fill", library: "default" }, + text: msg("About"), + }, }; private get isCrawler() { @@ -97,120 +108,130 @@ export class CollectionDetail extends BtrixElement { protected async updated( changedProperties: PropertyValues & Map, ) { - if (changedProperties.has("collection") && this.collection) { - void this.checkTruncateDescription(); + if ( + changedProperties.has("isEditingDescription") && + this.isEditingDescription + ) { + if (this.descriptionEditor) { + // FIXME Focus on editor ready instead of timeout + window.setTimeout(() => { + void this.descriptionEditor?.focus(); + }, 200); + } } } render() { return html`
${this.renderBreadcrumbs()}
-
-
-
- ${choose(this.collection?.access, [ - [ - CollectionAccess.Private, - () => html` - - - - `, - ], - [ - CollectionAccess.Unlisted, - () => html` - - - - `, - ], - [ - CollectionAccess.Public, - () => html` - - - - `, - ], - ])} -
-

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

-
- ) => { - e.stopPropagation(); - void this.updateVisibility(e.detail.item.value); - }} - > - ${when(this.isCrawler, this.renderActions)} -
-
+ ${pageHeader({ + title: this.collection?.name, + border: false, + prefix: this.renderAccessIcon(), + secondary: this.collection?.caption + ? html`
+ ${this.collection.caption} +
` + : nothing, + actions: html` + { + e.stopPropagation(); + void this.fetchCollection(); + }} + > + ${when(this.isCrawler, this.renderActions)} + `, + })} + +
${this.renderInfoBar()}
-
+
${this.renderTabs()} - ${when( - this.isCrawler, - () => html` - (this.openDialogName = "editItems")} - ?disabled=${!this.collection} - > - - ${msg("Select Items")} - - `, + ${when(this.isCrawler, () => + choose(this.collectionTab, [ + [ + Tab.Replay, + () => html` + + (this.openDialogName = "editStartPage")} + ?disabled=${!this.collection?.crawlCount} + > + + ${msg("Configure Home")} + + + `, + ], + [ + Tab.About, + () => + this.isEditingDescription + ? html` +
+ void this.saveDescription()} + ?disabled=${!this.collection} + > + + ${msg("Save")} + + (this.isEditingDescription = false)} + > + ${msg("Cancel")} + +
+ ` + : html` + (this.isEditingDescription = true)} + ?disabled=${!this.collection} + > + + ${msg("Edit")} + + `, + ], + [ + Tab.Items, + () => html` + (this.openDialogName = "editItems")} + ?disabled=${!this.collection} + > + + ${msg("Select Items")} + + `, + ], + ]), )}
- ${choose( - this.collectionTab, + ${choose(this.collectionTab, [ + [Tab.Replay, () => guard([this.collection], this.renderReplay)], [ - ["replay", () => guard([this.collection], this.renderReplay)], - [ - "items", - () => guard([this.archivedItems], this.renderArchivedItems), - ], + Tab.Items, + () => guard([this.archivedItems], this.renderArchivedItems), ], - - () => html``, - )} -
${this.renderDescription()}
+ [Tab.About, () => this.renderDescription()], + ])} + + { + this.openDialogName = undefined; + + // Don't do full refresh of rwp so that rwp-url-change fires + this.isRwpLoaded = false; + + await this.fetchCollection(); + await this.updateComplete; + }} + collectionId=${this.collectionId} + .homeUrl=${this.collection?.homeUrl} + .homePageId=${this.collection?.homeUrlPageId} + .homeTs=${this.collection?.homeUrlTs} + ?replayLoaded=${this.isRwpLoaded} + > + ${when( this.collection, () => html` @@ -268,6 +308,56 @@ export class CollectionDetail extends BtrixElement { )}`; } + private renderAccessIcon() { + return choose(this.collection?.access, [ + [ + CollectionAccess.Private, + () => html` + + + + `, + ], + [ + CollectionAccess.Unlisted, + () => html` + + + + `, + ], + [ + CollectionAccess.Public, + () => html` + + + + `, + ], + ]); + } + private refreshReplay() { if (this.replayEmbed) { try { @@ -295,8 +385,10 @@ export class CollectionDetail extends BtrixElement { private readonly renderTabs = () => { return html`