From 1277f789a8b8e13a70067e0d8e950715bc3203d1 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 8 Jun 2026 12:00:29 -0700 Subject: [PATCH 1/3] feat: Show collection slug when shareable (#3347) - Displays updated collection slug near name - Updates collection header layout to match header spacing on other pages - Fixes editable text field pencil icon hidden when name is too long - Fixes editable text field placeholder width --------- Co-authored-by: Emma Segal-Grossman --- frontend/src/components/ui/button.ts | 6 +- .../src/components/ui/editable-text-field.ts | 55 +++- .../features/collections/share-collection.ts | 45 +-- .../collection-detail/collection-detail.ts | 278 ++++++++---------- 4 files changed, 195 insertions(+), 189 deletions(-) diff --git a/frontend/src/components/ui/button.ts b/frontend/src/components/ui/button.ts index 97466fb125..b8788d2ff5 100644 --- a/frontend/src/components/ui/button.ts +++ b/frontend/src/components/ui/button.ts @@ -36,7 +36,10 @@ export class Button extends TailwindElement { label?: string; @property({ type: String }) - href?: string; + href?: HTMLAnchorElement["href"]; + + @property({ type: String }) + target?: HTMLAnchorElement["target"]; @property({ type: String }) download?: string; @@ -108,6 +111,7 @@ export class Button extends TailwindElement { )} ?disabled=${this.disabled} href=${ifDefined(this.href)} + target=${ifDefined(this.target)} download=${ifDefined(this.download)} aria-label=${ifDefined(this.label)} @click=${this.handleClick} diff --git a/frontend/src/components/ui/editable-text-field.ts b/frontend/src/components/ui/editable-text-field.ts index af263284d8..2145ff9195 100644 --- a/frontend/src/components/ui/editable-text-field.ts +++ b/frontend/src/components/ui/editable-text-field.ts @@ -7,11 +7,15 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { styleMap } from "lit/directives/style-map.js"; import { TailwindElement } from "@/classes/TailwindElement"; -import { type BtrixChangeEventDetail } from "@/events/btrix-change"; +import type { BtrixChangeEvent } from "@/events/btrix-change"; +import type { BtrixInputEvent } from "@/events/btrix-input"; import localize from "@/utils/localize"; import { measureTextWithElement } from "@/utils/measure-text"; import { tw } from "@/utils/tailwind"; +export type EditableTextFieldInputEvent = BtrixInputEvent; +export type EditableTextFieldChangeEvent = BtrixChangeEvent; + @customElement("btrix-editable-text-field") @localized() export class EditableTextField extends TailwindElement { @@ -119,11 +123,14 @@ export class EditableTextField extends TailwindElement { if (this.checkValidity()) { if (this.editing) { this.dispatchEvent( - new CustomEvent>("btrix-change", { - detail: { value: this.inputValue }, - bubbles: true, - composed: true, - }), + new CustomEvent( + "btrix-change", + { + detail: { value: this.inputValue }, + bubbles: true, + composed: true, + }, + ), ); } this.endEditing(); @@ -143,7 +150,7 @@ export class EditableTextField extends TailwindElement { updatePlaceholderWidth() { if (!this.placeholder) return; const width = measureTextWithElement(this.placeholder, this).width; - if (width) this.placeholderWidth = width; + if (width) this.placeholderWidth = width + this.extraWidth; } willUpdate(changedProperties: PropertyValues) { @@ -184,14 +191,26 @@ export class EditableTextField extends TailwindElement { return html` { + @input=${async (e: Event) => { this.inputValue = (e.target as HTMLInputElement).value; this.checkValidity(); + + await this.updateComplete; + this.dispatchEvent( + new CustomEvent( + "btrix-input", + { + detail: { value: this.inputValue }, + bubbles: true, + composed: true, + }, + ), + ); }} @focus=${() => { this.startEditing(); @@ -206,22 +225,26 @@ export class EditableTextField extends TailwindElement { /> ${this.inputValue - ? this.renderContent - ? this.renderContent(this.inputValue) - : this.inputValue - : this.placeholder} + ${this.inputValue + ? this.renderContent + ? this.renderContent(this.inputValue) + : this.inputValue + : this.placeholder} + ${this.maxLength && !this.valid ? html` ${this.showUnsavedWarning ? html`${msg("Unsaved")} - ` : null} ${localize.number(this.inputValue.length)} / diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts index 04ea08264f..bfb7ddffd8 100644 --- a/frontend/src/features/collections/share-collection.ts +++ b/frontend/src/features/collections/share-collection.ts @@ -68,26 +68,31 @@ export class ShareCollection extends BtrixElement { return html`
- this.shareLink} - content=${msg("Copy Public Link")} - @click=${() => { - void this.clipboardController.copy(this.shareLink); - - if ( - this.collection && - this.collection.access === CollectionAccess.Public - ) { - track(AnalyticsTrackEvent.CopyShareCollectionLink, { - org_slug: this.orgSlug, - collection_slug: this.collection.slug, - logged_in: !!this.authState, - }); - } - }} - > + ${when( + this.context === "public", + () => html` + this.shareLink} + content=${msg("Copy Public Link")} + @click=${() => { + void this.clipboardController.copy(this.shareLink); + + if ( + this.collection && + this.collection.access === CollectionAccess.Public + ) { + track(AnalyticsTrackEvent.CopyShareCollectionLink, { + org_slug: this.orgSlug, + collection_slug: this.collection.slug, + logged_in: !!this.authState, + }); + } + }} + > + `, + )} - ${this.renderBreadcrumbs()} - ${this.collection && - (this.collection.access === CollectionAccess.Unlisted || - this.collection.access === CollectionAccess.Public) - ? html` - - - ${this.collection.access === CollectionAccess.Unlisted - ? msg("Go to Unlisted Page") - : msg("Go to Public Page")} - - ` - : nothing} -
+
${this.renderBreadcrumbs()}
-
- ${this.renderAccessIcon()}${pageTitle( - this.isCrawler - ? html`) => { - void this.updateName(e.detail.value); - }} - extraWidth=${24} - > - - ` - : this.collection?.name, - tw`mb-2 h-6 w-60`, - tw`grid`, +
+
+ ${pageTitle( + this.isCrawler + ? html` { + e.stopPropagation(); + + const { value } = e.detail; + + this.slugPreview = value ? slugifyStrict(value) : ""; + }} + @btrix-change=${(e: EditableTextFieldChangeEvent) => { + e.stopPropagation(); + + const { value } = e.detail; + + if (value === this.collection?.name) { + this.slugPreview = ""; + } + + void this.updateName(value); + }} + extraWidth=${24} + > + + ` + : this.collection?.name, + tw`mb-2 h-6 w-60`, + tw`grid`, + )} +
+
${this.renderAccessDetails()}
${this.isCrawler ? when( @@ -312,8 +325,8 @@ export class CollectionDetail extends BtrixElement { `, ) @@ -321,7 +334,7 @@ export class CollectionDetail extends BtrixElement {
{ + if (!this.collection) { + return html``; + } + + const badge = html` + + ${SelectCollectionAccess.Options[this.collection.access].label} + `; + + const publicLink = () => { + const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + const namespacedPath = `${RouteNamespace.PublicOrgs}/${this.viewState?.params.slug}/${OrgTab.Collections}`; + const slugPreview = this.slugPreview || this.collection?.slug || ""; + const link = new URL(`${baseUrl}/${namespacedPath}/${slugPreview}`).href; + + return html` + ${toShortUrl(baseUrl, null)}/.../${slugPreview} + + ${this.slugPreview + ? nothing + : html` + + + + + `}`; + }; + + return html`
+ ${badge} + ${when(this.collection.access !== CollectionAccess.Private, publicLink)} +
`; + }; + private readonly renderCaption = (text: string) => richText(text, { linkClass: tw`text-cyan-500 transition-colors hover:text-cyan-600`, }); - private renderAccessIcon() { - return choose(this.collection?.access, [ - [ - CollectionAccess.Private, - () => html` - - ${this.isCrawler - ? html` { - this.openDialogName = "edit"; - this.editTab = "sharing"; - }} - >` - : html` - - `} - - `, - ], - [ - CollectionAccess.Unlisted, - () => html` - - ${this.isCrawler - ? html` - { - this.openDialogName = "edit"; - this.editTab = "sharing"; - }} - > - ` - : html` - - `} - - `, - ], - [ - CollectionAccess.Public, - () => html` - - ${this.isCrawler - ? html` - { - this.openDialogName = "edit"; - this.editTab = "sharing"; - }} - > - ` - : html` - - `} - - `, - ], - ]); - } - private refreshReplay() { if (this.replayEmbed) { try { @@ -1562,7 +1530,10 @@ export class CollectionDetail extends BtrixElement { } private async updateName(name: string) { - if (name === this.collection?.name) return; + if (name === this.collection?.name) { + return; + } + try { await this.api.fetch( `/orgs/${this.orgId}/collections/${this.collectionId}`, @@ -1586,10 +1557,13 @@ export class CollectionDetail extends BtrixElement { this.collection = { ...this.collection, name, + slug: this.slugPreview || this.collection.slug || "", }; } - void this.fetchCollection(); + await this.fetchCollection(); + + this.slugPreview = ""; } catch (err) { console.debug(err); From b4f492b6e00c94cf590abffba42a197b0e772022 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Fri, 19 Jun 2026 10:31:38 -0700 Subject: [PATCH 2/3] feat: Simplify choosing collection thumbnail and homepage (#3350) - Displays editable collection thumbnail in detail page header - Allows setting collection homepage directly from replay - Allows editing and displaying multi-line caption - Removes "Presentation" edit dialog - Updates share buttons - Fixes replay cache not cleared on collection updates --- frontend/src/components/ui/button.ts | 2 +- .../src/components/ui/editable-text-box.ts | 315 ++++++ .../src/components/ui/editable-text-field.ts | 13 +- frontend/src/components/ui/index.ts | 1 + frontend/src/components/ui/popover.ts | 12 + frontend/src/components/ui/prose.ts | 67 +- .../collections/collection-create-dialog.ts | 5 +- .../collections/collection-edit-dialog.ts | 330 ++----- .../collection-initial-view-dialog.ts | 417 -------- .../collection-snapshot-preview.ts | 250 ----- .../collections/collection-thumbnail.ts | 41 +- .../features/collections/collections-grid.ts | 4 +- .../edit-dialog/helpers/check-changed.ts | 27 +- .../edit-dialog/helpers/gather-state.ts | 16 +- .../edit-dialog/helpers/submit-task.ts | 96 +- .../edit-dialog/presentation-section.ts | 330 ------- .../edit-dialog/sharing-section.ts | 6 +- frontend/src/features/collections/index.ts | 2 +- .../select-collection-thumbnail.ts | 783 +++++++++++++++ .../features/collections/share-collection.ts | 46 +- .../collection-detail/collection-detail.ts | 903 ++++++++++++------ .../context/collection-rwp.ts | 11 + .../src/pages/org/collection-detail/types.ts | 1 - .../utils/getThumbnailBlob.ts | 36 + frontend/src/pages/org/collections-list.ts | 4 +- frontend/src/replayWebPage.d.ts | 2 +- frontend/src/theme.stylesheet.css | 5 +- frontend/src/types/page.ts | 42 + 28 files changed, 2065 insertions(+), 1702 deletions(-) create mode 100644 frontend/src/components/ui/editable-text-box.ts delete mode 100644 frontend/src/features/collections/collection-initial-view-dialog.ts delete mode 100644 frontend/src/features/collections/collection-snapshot-preview.ts delete mode 100644 frontend/src/features/collections/edit-dialog/presentation-section.ts create mode 100644 frontend/src/features/collections/select-collection-thumbnail.ts create mode 100644 frontend/src/pages/org/collection-detail/context/collection-rwp.ts create mode 100644 frontend/src/pages/org/collection-detail/utils/getThumbnailBlob.ts create mode 100644 frontend/src/types/page.ts diff --git a/frontend/src/components/ui/button.ts b/frontend/src/components/ui/button.ts index b8788d2ff5..b52b35bca5 100644 --- a/frontend/src/components/ui/button.ts +++ b/frontend/src/components/ui/button.ts @@ -72,7 +72,7 @@ export class Button extends TailwindElement { this.disabled ? tw`cursor-not-allowed opacity-50` : tw`cursor-pointer`, tw`flex items-center justify-center gap-2 text-center font-medium outline-3 outline-offset-1 outline-primary transition focus-visible:outline`, { - "x-small": tw`min-h-4 min-w-4 text-sm`, + "x-small": tw`min-h-4 min-w-4 rounded-sm text-sm`, 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], diff --git a/frontend/src/components/ui/editable-text-box.ts b/frontend/src/components/ui/editable-text-box.ts new file mode 100644 index 0000000000..b07a5a752d --- /dev/null +++ b/frontend/src/components/ui/editable-text-box.ts @@ -0,0 +1,315 @@ +import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; +import { css, html, type PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { styleMap } from "lit/directives/style-map.js"; +import debounce from "lodash/fp/debounce"; + +import type { Prose, ProseClampingEvent } from "./prose"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import type { BtrixChangeEvent } from "@/events/btrix-change"; +import type { BtrixInputEvent } from "@/events/btrix-input"; +import type { UnderlyingFunction } from "@/types/utils"; +import localize from "@/utils/localize"; +import { measureTextWithElement } from "@/utils/measure-text"; +import { richText } from "@/utils/rich-text"; +import { tw } from "@/utils/tailwind"; +import { definitelyUrl } from "@/utils/url-helpers"; + +export type EditableTextBoxInputEvent = BtrixInputEvent; +export type EditableTextBoxChangeEvent = BtrixChangeEvent; + +const newlineRegex = /[\r\n]+/gm; +const WORD_MAX_WIDTH = 300; +const WORD_MAX_LENGTH = 50; + +/** + * In-place editor for multi-line text. + */ +@customElement("btrix-editable-text-box") +@localized() +export class EditableTextBox extends TailwindElement { + static styles = css` + :host { + display: block; + position: relative; + } + `; + + @property({ type: String }) + label = ""; + + @property({ type: String }) + value = ""; + + @property({ type: String }) + placeholder = ""; + + @property({ type: Number }) + clamp?: number; + + @property({ type: Boolean }) + plainText = false; + + @property({ type: Boolean }) + allowNewLines = false; + + @property({ type: Number }) + minLength?: number; + + @property({ type: Number }) + maxLength?: number; + + @state() + editing = false; + + @state() + private inputValue = ""; + + @state() + private clamping = false; + + @state() + private valid: boolean | undefined = true; + + @state() + private showUnsavedWarning = false; + + @query("textarea") + private readonly textarea?: HTMLTextAreaElement | null; + + @query("btrix-prose") + private readonly prose?: Prose | null; + + // Check if value contains any words over the length limit + private containsLongWord = false; + + private readonly handleKeydown = (e: KeyboardEvent) => { + if (!this.allowNewLines) { + if (e.key === "Enter") { + e.preventDefault(); + this.save(); + } + } + if (e.key === "Escape") { + this.endEditing(false); + } + }; + + private readonly handlePaste = (e: ClipboardEvent) => { + if (!this.allowNewLines) { + e.preventDefault(); + + const text = e.clipboardData?.getData("text") ?? ""; + const modifiedText = text.replace(newlineRegex, " "); + + document.execCommand("insertText", false, modifiedText); + } + }; + + private readonly handleInput = async (e: InputEvent) => { + const textarea = e.currentTarget as HTMLTextAreaElement; + let value = (e.target as HTMLTextAreaElement).value; + + if (!this.allowNewLines) { + value = value.replace(newlineRegex, ""); + textarea.value = value; + } + + this.inputValue = value; + this.checkValidity(); + + await this.updateComplete; + + this.dispatchEvent( + new CustomEvent("btrix-input", { + detail: { value: this.inputValue }, + bubbles: true, + composed: true, + }), + ); + }; + + startEditing() { + this.editing = true; + if (this.textarea) { + this.textarea.value = this.value; + } + } + + endEditing(save = true) { + this.editing = false; + this.showUnsavedWarning = false; + if (!save) { + this.inputValue = this.value; + } + this.valid = true; + + if (this.textarea) { + this.textarea.value = ""; + this.textarea.blur(); + } + } + + save() { + if (this.checkValidity()) { + if (this.editing) { + this.dispatchEvent( + new CustomEvent( + "btrix-change", + { + detail: { value: this.inputValue }, + bubbles: true, + composed: true, + }, + ), + ); + } + this.value = this.inputValue; + this.endEditing(); + } else { + this.showUnsavedWarning = true; + } + } + + checkValidity() { + let valid = true; + if (this.minLength && this.inputValue.length < this.minLength) { + valid = false; + } + if (this.maxLength && this.inputValue.length > this.maxLength) { + valid = false; + } + this.valid = valid; + return valid; + } + + disconnectedCallback(): void { + this.debouncedOnResize.cancel(); + super.disconnectedCallback(); + } + + willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("value")) { + this.inputValue = this.value; + + if (this.value) { + this.containsLongWord = this.value.split(/\s/).some((str) => { + const measurement = measureTextWithElement(str, this); + + // URLs get truncated by `richText()` + if (definitelyUrl(str)) return; + + if (measurement.width) { + return measurement.width > WORD_MAX_WIDTH; + } + + // Fallback to character length + return str.length > WORD_MAX_LENGTH; + }); + } + } + } + + updated(changedProperties: PropertyValues) { + if (changedProperties.has("editing")) { + if (this.editing) { + // Reset clamping to recalculate when editing ends + if (this.prose) { + this.prose.clamped = undefined; + } + this.textarea?.focus(); + } else if (changedProperties.get("editing") !== undefined) { + void this.prose?.syncClamp(); + } + } + } + + render() { + return html` + { + this.clamping = e.detail.clamping; + }} + >${this.value + ? this.plainText + ? this.value + : richText(this.value, { + linkClass: tw`relative z-[2] text-cyan-500 transition-colors hover:text-cyan-600`, + }) + : html``} + + + ${this.maxLength && !this.valid + ? html` + ${this.showUnsavedWarning ? html`${msg("Unsaved")} - ` : null} + ${localize.number(this.inputValue.length)} / + ${localize.number(this.maxLength)} + ` + : null} + + } + > +
+
+ `; + } + + private firstResize = true; + + private readonly onResize = () => { + if (this.firstResize) { + this.firstResize = false; + return; + } + + void this.prose?.syncClamp(); + }; + + private readonly debouncedOnResize = debounce(200)(this.onResize); +} diff --git a/frontend/src/components/ui/editable-text-field.ts b/frontend/src/components/ui/editable-text-field.ts index 2145ff9195..547dea5014 100644 --- a/frontend/src/components/ui/editable-text-field.ts +++ b/frontend/src/components/ui/editable-text-field.ts @@ -16,18 +16,21 @@ import { tw } from "@/utils/tailwind"; export type EditableTextFieldInputEvent = BtrixInputEvent; export type EditableTextFieldChangeEvent = BtrixChangeEvent; +/** + * In-place editor for single-line text. + */ @customElement("btrix-editable-text-field") @localized() export class EditableTextField extends TailwindElement { + @property({ type: String }) + label = ""; + @property({ type: String }) value = ""; @state() inputValue = ""; - @property({ type: String }) - innerClass = ""; - @state() editing = false; @@ -188,12 +191,14 @@ export class EditableTextField extends TailwindElement { render() { const minWidth = Math.max(this.placeholderWidth, this.width, 1); - return html`${this.label} + { diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index b847c50e65..a11338fed3 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -19,6 +19,7 @@ import("./copy-field"); import("./tag-container"); import("./data-grid"); import("./details"); +import("./editable-text-box"); import("./editable-text-field"); import("./file-input"); import("./file-list"); diff --git a/frontend/src/components/ui/popover.ts b/frontend/src/components/ui/popover.ts index 0e1654adb5..37451d58d7 100644 --- a/frontend/src/components/ui/popover.ts +++ b/frontend/src/components/ui/popover.ts @@ -3,6 +3,8 @@ import slTooltipStyles from "@shoelace-style/shoelace/dist/components/tooltip/to import { css } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { stopProp } from "@/utils/events"; + /** * Popovers are used to reveal supplementary information, like additional context or details. * They're hidden until an anchor is activated, e.g. on hover. @@ -25,6 +27,16 @@ export class Popover extends SlTooltip { @property({ type: String, reflect: true }) placement: SlTooltip["placement"] = "bottom"; + connectedCallback(): void { + super.connectedCallback(); + + // Stop propagation to prevent dialogs and dropdowns from closing + this.addEventListener("sl-show", stopProp); + this.addEventListener("sl-after-show", stopProp); + this.addEventListener("sl-hide", stopProp); + this.addEventListener("sl-after-hide", stopProp); + } + static styles = [ slTooltipStyles, css` diff --git a/frontend/src/components/ui/prose.ts b/frontend/src/components/ui/prose.ts index 0accf72451..aeb34a91fb 100644 --- a/frontend/src/components/ui/prose.ts +++ b/frontend/src/components/ui/prose.ts @@ -1,17 +1,24 @@ import { localized, msg } from "@lit/localize"; import clsx from "clsx"; -import { css, html, nothing } from "lit"; -import { customElement, queryAsync, state } from "lit/decorators.js"; +import { css, html, nothing, type PropertyValues } from "lit"; +import { customElement, property, queryAsync } from "lit/decorators.js"; import { TailwindElement } from "@/classes/TailwindElement"; import { tw } from "@/utils/tailwind"; +export type ProseClampingEvent = CustomEvent<{ + clamping: boolean; + clamped: boolean; +}>; + /** * Display prose, like workflow and item descriptions, with line clamping. - * Uses `overflow-hidden` as fallback * * @cssproperty --btrix-line-clamp - * @cssproperty --btrix-prose-width + * @cssPart base + * @cssPart content + * @cssPart button + * @fires btrix-prose-clamping */ @customElement("btrix-prose") @localized() @@ -19,42 +26,60 @@ export class Prose extends TailwindElement { static styles = css` :host { --btrix-line-clamp: 6; - --btrix-prose-width: 65ch; display: contents; } - - .clamp { - max-height: calc(var(--btrix-line-clamp) * 1.3125rem); - } `; - @state() - private clamped?: boolean; + @property({ type: Boolean }) + clamped?: boolean; @queryAsync("pre") private readonly pre?: Promise; + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.has("clamped")) { + this.dispatchEvent( + new CustomEvent("btrix-prose-clamping", { + detail: { + clamping: this.clamped !== undefined, + clamped: this.clamped === true, + }, + }), + ); + } + } + render() { - return html`
+ return html`
+
 {
+            // Revert scroll that happens when tabbing to a focusable child
+            (e.currentTarget as HTMLPreElement).scrollTo(0, 0);
+          }}
+        >
+ +
${this.clamped || this.clamped === false ? html`` : nothing}`; } - private async onSlotChange() { + private onSlotChange() { + void this.syncClamp(); + } + + async syncClamp() { const pre = await this.pre; if (!pre) { diff --git a/frontend/src/features/collections/collection-create-dialog.ts b/frontend/src/features/collections/collection-create-dialog.ts index bbb8d59f9a..be6fa8587d 100644 --- a/frontend/src/features/collections/collection-create-dialog.ts +++ b/frontend/src/features/collections/collection-create-dialog.ts @@ -141,11 +141,12 @@ export class CollectionCreateDialog extends BtrixElement { }} > - @@ -167,7 +168,7 @@ export class CollectionCreateDialog extends BtrixElement { > - + ; - @query("btrix-select-collection-page") - readonly thumbnailSelector?: SelectCollectionPage; - - @query("btrix-collection-snapshot-preview") - public readonly thumbnailPreview?: CollectionSnapshotPreview | null; - protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("tab")) { this.dispatchEvent( @@ -139,15 +94,6 @@ export class CollectionEdit extends BtrixElement { this.onReset(); this.collection = undefined; } - if ( - changedProperties.has("collection") && - changedProperties.get("collection")?.id != this.collection?.id - ) { - this.defaultThumbnailName = - (this.collection?.defaultThumbnailName as `${Thumbnail}` | null) || - null; - this.selectedSnapshot = this.collection?.thumbnailSource ?? null; - } } readonly checkChanged = checkChanged.bind(this); @@ -186,16 +132,8 @@ export class CollectionEdit extends BtrixElement { private onReset() { void this.hideDialog(); - void this.thumbnailSelector?.resetFormState(); this.dirty = false; this.errorTab = null; - this.blobIsLoaded = false; - this.selectedSnapshot = this.collection?.thumbnailSource ?? null; - this.defaultThumbnailName = - (this.collection?.defaultThumbnailName as - | `${Thumbnail}` - | null - | undefined) || null; } protected firstUpdated(): void { @@ -205,183 +143,103 @@ export class CollectionEdit extends BtrixElement { } render() { - return html` (this.isDialogVisible = true)} - @sl-after-hide=${() => { - this.isDialogVisible = false; - // Reset the open tab when closing the dialog - this.tab = "general"; - }} - @sl-request-close=${(e: SlRequestCloseEvent) => { - if (e.detail.source === "close-button") { - this.onReset(); - return; - } - // Prevent accidental closes unless data has been saved - // Closing via the close buttons is fine though, cause it resets the form first. - if (this.dirty) e.preventDefault(); - }} - class="h-full [--width:var(--btrix-screen-desktop)]" - >${this.isDialogVisible - ? html` - ${this.collection - ? html` -
{ - void this.checkChanged(); - }} - @sl-input=${() => { - void this.checkChanged(); - }} - @sl-change=${() => { - void this.checkChanged(); - }} - > - ) => { - this.tab = e.detail; - }} - class="part-[content]:pt-4" - > - ${this.renderTab({ - panel: "general", - icon: "easel3-fill", - string: msg("Presentation"), - })} - ${this.renderTab({ - panel: "sharing", - icon: "globe", - string: msg("Sharing"), - })} - - - ${renderPresentation.bind(this)()} - - - - - - - -
- ` - : html` -
- -
- `} -
- { - // Using reset method instead of type="reset" fixes - // incorrect getRootNode in Chrome - (await this.form).reset(); - }} - >${this.dirty - ? msg("Discard Changes") - : msg("Cancel")} - ${this.dirty - ? html`${msg("Unsaved changes.")}` - : nothing} - ${this.errorTab !== null - ? html`${msg("Please review issues with your changes.")}` - : nothing} - { - // Using submit method instead of type="submit" fixes - // incorrect getRootNode in Chrome - const form = await this.form; - const submitInput = form.querySelector( - 'input[type="submit"]', - ); - form.requestSubmit(submitInput); - }} - >${msg("Save")} -
- ` - : nothing} -
- ${this.renderReplay()}`; - } - - private renderReplay() { - if (this.replayWebPage) return; - if (!this.collection) return; - if (!this.isDialogVisible) return; - if (!this.collection.crawlCount) return; + const collection_name = this.collection?.name; - const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`; - const headers = this.authState?.headers; - const config = JSON.stringify({ headers }); - - return html``; - } - - private renderTab({ - panel, - icon, - string, - }: { - panel: Tab; - icon: string; - string: string; - }) { - return html` - - ${string} - `; + class="[--width:40rem]" + >${this.isDialogVisible + ? html` + ${this.collection + ? html` +
{ + void this.checkChanged(); + }} + @sl-input=${() => { + void this.checkChanged(); + }} + @sl-change=${() => { + void this.checkChanged(); + }} + > + + +
+ ` + : html` +
+ +
+ `} +
+ { + // Using reset method instead of type="reset" fixes + // incorrect getRootNode in Chrome + (await this.form).reset(); + }} + >${this.dirty + ? msg("Discard Changes") + : msg("Cancel")} + ${this.dirty + ? html`${msg("Unsaved changes.")}` + : nothing} + ${this.errorTab !== null + ? html`${msg("Please review issues with your changes.")}` + : nothing} + { + // Using submit method instead of type="submit" fixes + // incorrect getRootNode in Chrome + const form = await this.form; + const submitInput = form.querySelector( + 'input[type="submit"]', + ); + form.requestSubmit(submitInput); + }} + >${msg("Save")} +
+ ` + : nothing} + `; } private async fetchCollection(id: string) { diff --git a/frontend/src/features/collections/collection-initial-view-dialog.ts b/frontend/src/features/collections/collection-initial-view-dialog.ts deleted file mode 100644 index 7825d2d5f7..0000000000 --- a/frontend/src/features/collections/collection-initial-view-dialog.ts +++ /dev/null @@ -1,417 +0,0 @@ -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 { - HomeView, - type BtrixValidateDetail, - type CollectionSnapshotPreview, -} from "./collection-snapshot-preview"; -import type { SelectSnapshotDetail } from "./select-collection-page"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { Dialog } from "@/components/ui/dialog"; -import type { Collection } from "@/types/collection"; - -/** - * @fires btrix-change - */ -@localized() -@customElement("btrix-collection-initial-view-dialog") -export class CollectionInitialViewDialog extends BtrixElement { - static readonly Options: Record< - HomeView, - { label: string; icon: NonNullable; detail: string } - > = { - [HomeView.Pages]: { - label: msg("List of Pages"), - icon: "list-ul", - detail: `${msg("ReplayWeb.Page default view")}`, - }, - [HomeView.URL]: { - label: msg("Start Page"), - icon: "file-earmark-richtext", - detail: msg("Show a single URL snapshot"), - }, - }; - - @property({ type: String }) - collectionId?: string; - - @property({ type: Object }) - collection?: Collection; - - @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?: CollectionSnapshotPreview | null; - - @state() - validThumbnail = false; - - willUpdate(changedProperties: PropertyValues) { - if (changedProperties.has("collection") && this.collection) { - this.homeView = this.collection.homeUrl ? HomeView.URL : HomeView.Pages; - } - } - - render() { - const showTooltip = - this.homeView === HomeView.URL && !this.selectedSnapshot; - return html` - (this.showContent = true)} - @sl-after-hide=${() => { - if (this.collection) { - this.homeView = this.collection.homeUrl - ? HomeView.URL - : HomeView.Pages; - } - - this.isSubmitting = false; - this.selectedSnapshot = null; - 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() { - const snapshot = - this.selectedSnapshot || - (this.collection?.homeUrl - ? { - url: this.collection.homeUrl, - ts: this.collection.homeUrlTs, - pageId: this.collection.homeUrlPageId, - status: 200, - } - : null); - - 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` -
- ) => { - console.log({ valid }); - this.validThumbnail = valid; - }} - > - - - ${when( - !this.replayLoaded, - () => html` -
- -
- `, - )} -
- `; - } - - private renderForm() { - const { icon, detail } = CollectionInitialViewDialog.Options[this.homeView]; - - return html` -
- { - this.homeView = (e.currentTarget as SlSelect).value as HomeView; - - if (this.homeView === HomeView.Pages) { - if ( - !this.collection?.homeUrlPageId || - this.collection.homeUrlPageId !== this.selectedSnapshot?.pageId - ) { - // Reset unsaved selected snapshot - this.selectedSnapshot = null; - } - } - }} - > - ${this.replayLoaded - ? html`` - : html``} - - ${detail} - - ${Object.values(HomeView).map((homeView) => { - const { label, icon, detail } = - CollectionInitialViewDialog.Options[homeView]; - return html` - - - ${label} - ${detail} - - `; - })} - - - ${when( - this.homeView === HomeView.URL, - () => html` - -
- , - ) => { - this.selectedSnapshot = e.detail.item; - }} - > - - - ${msg("Update collection thumbnail")} - -
- `, - )} -
- `; - } - - private async onSubmit(e: SubmitEvent) { - e.preventDefault(); - - const form = e.currentTarget as HTMLFormElement; - const { homeView, useThumbnail } = serialize(form); - - if ( - (homeView === HomeView.Pages && !this.collection?.homeUrlPageId) || - (homeView === HomeView.URL && - this.selectedSnapshot && - this.collection?.homeUrlPageId === this.selectedSnapshot.pageId) - ) { - // No changes to save - this.open = false; - return; - } - - 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.collection?.thumbnailSource?.urlPageId !== - this.selectedSnapshot.pageId; - // TODO get filename from rwp? - const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`; - let file: File | undefined; - - if (shouldUpload && this.thumbnailPreview) { - const blob = await this.thumbnailPreview.thumbnailBlob; - - if (blob) { - file = new File([blob], fileName, { - type: blob.type, - }); - } - } 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 || !this.selectedSnapshot) - throw new Error("file or fileName missing"); - const searchParams = new URLSearchParams({ - filename: fileName, - sourceUrl: this.selectedSnapshot.url, - sourceTs: this.selectedSnapshot.ts, - sourcePageId: this.selectedSnapshot.pageId, - }); - const tasks = [ - this.api.upload( - `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?${searchParams.toString()}`, - file, - ), - this.updateThumbnail({ defaultThumbnailName: null }), - ]; - await Promise.all(tasks); - - 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", - }); - } - } - - this.dispatchEvent(new CustomEvent("btrix-change")); - } 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-snapshot-preview.ts b/frontend/src/features/collections/collection-snapshot-preview.ts deleted file mode 100644 index b6220867f6..0000000000 --- a/frontend/src/features/collections/collection-snapshot-preview.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { localized, msg } from "@lit/localize"; -import { Task } from "@lit/task"; -import clsx from "clsx"; -import { html, nothing, type PropertyValues } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; - -import type { SelectSnapshotDetail } from "./select-collection-page"; - -import { TailwindElement } from "@/classes/TailwindElement"; -import { isNotEqual } from "@/utils/is-not-equal"; -import { formatRwpTimestamp } from "@/utils/replay"; -import { tw } from "@/utils/tailwind"; - -export enum HomeView { - Pages = "pages", - URL = "url", -} - -export type BtrixValidateDetail = { - valid: boolean; -}; - -/** - * Display preview of page snapshot. - * - * A previously loaded `replay-web-page` embed is required in order for preview to work. - */ -@customElement("btrix-collection-snapshot-preview") -@localized() -export class CollectionSnapshotPreview extends TailwindElement { - @property({ type: String }) - collectionId = ""; - - @property({ type: String }) - replaySrc = ""; - - @property({ type: String }) - view?: HomeView; - - @property({ type: Boolean }) - noSpinner = false; - - @property({ - type: Object, - hasChanged: isNotEqual, - }) - snapshot?: Partial; - - @query("iframe") - private readonly iframe?: HTMLIFrameElement | null; - - @query("img#preview") - private readonly previewImg?: HTMLImageElement | null; - - @state() - private iframeLoaded = false; - - // Set up a promise and a helper callback so that we can wait until the iframe is loaded, rather than returning nothing when it's not yet loaded - private iframeLoadComplete!: () => void; - private readonly iframeLoadedPromise = new Promise((res) => { - if (this.iframeLoaded) res(); - this.iframeLoadComplete = res; - }); - - public get thumbnailBlob() { - return this.blobTask.taskComplete.then(() => this.blobTask.value); - } - - public readonly blobTask = new Task(this, { - task: async ([collectionId, snapshot], { signal }) => { - try { - console.debug("waiting for iframe to load", { collectionId, snapshot }); - await this.iframeLoadedPromise; - if ( - !collectionId || - !snapshot?.ts || - !snapshot.url || - !this.iframe?.contentWindow - ) { - console.debug( - "exiting early due to missing props", - collectionId, - snapshot, - this.iframe?.contentWindow, - ); - return; - } - - const resp = await this.iframe.contentWindow.fetch( - `/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`, - { signal }, - ); - - if (resp.status === 200) { - this.dispatchEvent( - new CustomEvent("btrix-validate", { - detail: { valid: true }, - }), - ); - return await resp.blob(); - } - - throw new Error(`couldn't get thumbnail`); - } catch (e) { - console.error(e); - if (signal.aborted) return; - this.dispatchEvent( - new CustomEvent("btrix-validate", { - detail: { valid: false }, - }), - ); - throw e; - } - }, - args: () => [this.collectionId, this.snapshot] as const, - }); - - @state() - private prevObjUrl?: string; - - private readonly objectUrlTask = new Task(this, { - task: ([blob]) => { - this.prevObjUrl = this.objectUrlTask.value; - if (!blob) return ""; - - const url = URL.createObjectURL(blob); - - if (url) return url; - - throw new Error("no object url"); - }, - args: () => [this.blobTask.value] as const, - }); - - disconnectedCallback(): void { - super.disconnectedCallback(); - - if (this.objectUrlTask.value) { - URL.revokeObjectURL(this.objectUrlTask.value); - } - } - - protected willUpdate(changedProperties: PropertyValues): void { - if ( - changedProperties.has("collectionId") || - changedProperties.has("snapshot") - ) { - // revoke object urls once the `` element has loaded the next url, to - // prevent flashes - - this.previewImg?.addEventListener("load", () => { - if (this.prevObjUrl) { - URL.revokeObjectURL(this.prevObjUrl); - this.prevObjUrl = undefined; - } - }); - } - } - - render() { - return html` ${this.renderSnapshot()} ${this.renderReplay()} `; - } - - private renderSnapshot() { - if (this.view === HomeView.Pages) return; - - return this.blobTask.render({ - complete: this.renderImage, - pending: this.renderImage, - error: this.renderError, - }); - } - - private readonly renderImage = () => { - if (!this.snapshot) { - if (this.noSpinner) return; - return html` -

- - ${msg("Enter a Page URL to preview it.")} - -

- `; - } - - return html` -
- - ${this.objectUrlTask.value ? nothing : this.renderSpinner()} -
- ${this.prevObjUrl - ? html`` - : nothing} - ${this.objectUrlTask.value - ? html`` - : nothing} -
- ${this.snapshot.url} -
-
- `; - }; - - private renderReplay() { - return html`
-
-
- -
-
-
`; - } - - private readonly renderError = () => html` -

- ${msg("This page doesn’t have a preview. Try another URL or timestamp.")} -

- `; - - private readonly renderSpinner = () => { - if (this.noSpinner) return; - return html` -
- -
- `; - }; -} diff --git a/frontend/src/features/collections/collection-thumbnail.ts b/frontend/src/features/collections/collection-thumbnail.ts index 05971ca3b0..7331a1dfdc 100644 --- a/frontend/src/features/collections/collection-thumbnail.ts +++ b/frontend/src/features/collections/collection-thumbnail.ts @@ -20,24 +20,32 @@ 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, - }, - }; + static readonly Variants: Record = + { + [Thumbnail.Cyan]: { + path: thumbnailCyanSrc, + label: msg("Cyan"), + }, + [Thumbnail.Green]: { + path: thumbnailGreenSrc, + label: msg("Lime"), + }, + [Thumbnail.Orange]: { + path: thumbnailOrangeSrc, + label: msg("Rust"), + }, + [Thumbnail.Yellow]: { + path: thumbnailYellowSrc, + label: msg("Amber"), + }, + }; @property({ type: String }) src?: string; + @property({ type: String }) + alt?: string; + @property({ type: String }) collectionName?: string; @@ -45,9 +53,10 @@ export class CollectionThumbnail extends BtrixElement { return html` ${this.collectionName `; diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts index ca141fcd4a..4092cb5405 100644 --- a/frontend/src/features/collections/collections-grid.ts +++ b/frontend/src/features/collections/collections-grid.ts @@ -182,7 +182,7 @@ export class CollectionsGrid extends BtrixElement {
${this.renderActions ? this.renderActions(collection) - : html` + : html` - + `}
diff --git a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts index a322b046e4..07bfdbe97f 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts @@ -28,8 +28,7 @@ type KVPairs = { export default async function checkChanged(this: CollectionEdit) { try { - const { collectionUpdate, thumbnail, setInitialView } = - await gatherState.bind(this)(); + const { collectionUpdate } = await gatherState.bind(this)(); const state: CollectionUpdate = { ...collectionUpdate, @@ -40,30 +39,8 @@ export default async function checkChanged(this: CollectionEdit) { // filter out unchanged properties const updates = pairs.filter( ([name, value]) => !checkEqual(this.collection!, name, value), - ) as KVPairs< - CollectionUpdate & { - thumbnail: typeof thumbnail; - setInitialView: typeof setInitialView; - } - >; + ) as KVPairs; - const shouldUpload = - thumbnail.selectedSnapshot && - !isEqual(this.collection?.thumbnailSource, thumbnail.selectedSnapshot) && - this.blobIsLoaded; - - if (shouldUpload) { - updates.push(["thumbnail", thumbnail]); - } - if (setInitialView) { - if ( - this.collection && - thumbnail.selectedSnapshot && - this.collection.homeUrlPageId !== thumbnail.selectedSnapshot.urlPageId - ) { - updates.push(["setInitialView", true]); - } - } if (updates.length > 0) { this.dirty = true; } else { diff --git a/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts index 971bc2a7f6..958bc31191 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts @@ -30,29 +30,15 @@ export default async function gatherState(this: CollectionEdit) { const { access, allowPublicDownload } = (await this.shareSettings) ?? {}; - const formData = serialize(form) as CollectionUpdate & { - setInitialView: boolean; - }; - - const selectedSnapshot = this.selectedSnapshot; - - if (this.defaultThumbnailName == null && !selectedSnapshot) { - formData.thumbnailSource = null; - } + const formData = serialize(form) as CollectionUpdate; - const { setInitialView } = formData; const data: CollectionUpdate = { ...formData, access, - defaultThumbnailName: this.defaultThumbnailName, allowPublicDownload, }; return { collectionUpdate: collectionUpdateSchema.parse(data), - thumbnail: { - selectedSnapshot, - }, - setInitialView, }; } diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts index 6eca074790..2903a3818c 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -3,12 +3,8 @@ import { type TaskFunction } from "@lit/task"; import { type CollectionEdit } from "../../collection-edit-dialog"; -import { - type CollectionThumbnailSource, - type CollectionUpdate, -} from "@/types/collection"; +import { type CollectionUpdate } from "@/types/collection"; import { isApiError } from "@/utils/api"; -import slugifyStrict from "@/utils/slugify"; export default function submitTask( this: CollectionEdit, @@ -18,86 +14,19 @@ export default function submitTask( try { const updates = await this.checkChanged(); if (!updates) throw new Error("invalid_data"); - const updateObject = Object.fromEntries(updates) as CollectionUpdate & { - thumbnail?: { - selectedSnapshot: CollectionThumbnailSource; - }; - setInitialView?: boolean; - }; - const { - thumbnail: { selectedSnapshot } = {}, - setInitialView, - ...rest - } = updateObject; - const tasks = []; + const updateObject = Object.fromEntries(updates) as CollectionUpdate; - // TODO get filename from rwp? - const fileName = `page-thumbnail_${selectedSnapshot?.urlPageId}.jpeg`; - let file: File | undefined; - - if (selectedSnapshot) { - const blob = await this.thumbnailPreview?.thumbnailBlob.catch(() => { - throw new Error("invalid_data"); - }); - if (blob) { - file = new File([blob], fileName, { - type: blob.type, - }); - } - if (!file) throw new Error("invalid_data"); - const searchParams = new URLSearchParams({ - filename: fileName, - sourceUrl: selectedSnapshot.url, - sourceTs: selectedSnapshot.urlTs, - sourcePageId: selectedSnapshot.urlPageId, - }); - tasks.push( - this.api.upload( - `/orgs/${this.orgId}/collections/${this.collection.id}/thumbnail?${searchParams.toString()}`, - file, + if (Object.keys(updateObject).length) { + await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/collections/${this.collection.id}`, + { + method: "PATCH", + body: JSON.stringify(updateObject), signal, - ), - ); - rest.defaultThumbnailName = null; - } - - if (setInitialView) { - tasks.push( - this.api.fetch( - `/orgs/${this.orgId}/collections/${this.collection.id}/home-url`, - { - method: "POST", - body: JSON.stringify({ - pageId: this.selectedSnapshot?.urlPageId, - }), - }, - ), - ); - } - - if (Object.keys(rest).length) { - const params = { - ...rest, - }; - - if (rest.name) { - params.slug = slugifyStrict(rest.name); - } - - tasks.push( - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collection.id}`, - { - method: "PATCH", - body: JSON.stringify(params), - signal, - }, - ), + }, ); } - await Promise.all(tasks); - this.dispatchEvent( new CustomEvent<{ id: string; @@ -110,10 +39,11 @@ export default function submitTask( }), ); this.dispatchEvent(new CustomEvent("btrix-change")); + + const collection_name = this.collection.name; + this.notify.toast({ - message: msg( - str`Updated collection “${this.name || this.collection.name}”`, - ), + message: msg(str`Updated collection “${collection_name}”`), variant: "success", icon: "check2-circle", id: "collection-metadata-status", diff --git a/frontend/src/features/collections/edit-dialog/presentation-section.ts b/frontend/src/features/collections/edit-dialog/presentation-section.ts deleted file mode 100644 index f4d913a00e..0000000000 --- a/frontend/src/features/collections/edit-dialog/presentation-section.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { msg } from "@lit/localize"; -import { TaskStatus } from "@lit/task"; -import { type SlInput } from "@shoelace-style/shoelace"; -import clsx from "clsx"; -import { html, nothing } from "lit"; -import { when } from "lit/directives/when.js"; -import { isEqual } from "lodash"; -import queryString from "query-string"; - -import { - validateCaptionMax, - validateNameMax, - type CollectionEdit, -} from "../collection-edit-dialog"; -import { type BtrixValidateDetail } from "../collection-snapshot-preview"; -import { - CollectionThumbnail, - DEFAULT_THUMBNAIL_VARIANT, - Thumbnail, -} from "../collection-thumbnail"; -import { type SelectSnapshotDetail } from "../select-collection-page"; - -import { snapshotToSource, sourceToSnapshot } from "./helpers/snapshots"; - -import type { PublicCollection } from "@/types/collection"; -import { tw } from "@/utils/tailwind"; - -export default function renderPresentation(this: CollectionEdit) { - if (!this.collection) return; - return html` { - this.validate(validateNameMax)(e); - this.name = (e.target as SlInput).value; - }} - > - - - - ${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.", - )} - - - - - -
${renderThumbnails.bind(this)()}
-
- ) => { - if (!e.detail.item) return; - const newSnapshot = snapshotToSource(e.detail.item); - if (!isEqual(newSnapshot, this.selectedSnapshot)) { - this.thumbnailIsValid = null; - this.selectedSnapshot = newSnapshot; - } - - void this.checkChanged(); - }} - > - ${this.thumbnailIsValid === false - ? html` - - ` - : this.thumbnailPreview?.blobTask.status === TaskStatus.PENDING && - !this.blobIsLoaded - ? html`` - : nothing} - - - ${msg("Set initial view to this page")} - -
`; -} - -function renderThumbnails(this: CollectionEdit) { - let selectedImgSrc: string | null = DEFAULT_THUMBNAIL_VARIANT.path; - - if (this.defaultThumbnailName) { - const variant = Object.entries(CollectionThumbnail.Variants).find( - ([name]) => name === this.defaultThumbnailName, - ); - - if (variant) { - selectedImgSrc = variant[1].path; - } - } else if (this.collection?.thumbnail) { - selectedImgSrc = this.collection.thumbnail.path; - } else { - selectedImgSrc = null; - } - - const thumbnail = ( - thumbnail?: Thumbnail | NonNullable, - ) => { - let name: Thumbnail | null = null; - let path = ""; - - if (!thumbnail) - return html` `; - - if (typeof thumbnail === "string") { - // we know that the thumbnail here is one of the placeholders - name = thumbnail; - path = CollectionThumbnail.Variants[name].path; - } else { - path = thumbnail.path; - } - - if (!path) { - console.error("no path for thumbnail:", thumbnail); - return; - } - - const isSelected = path === selectedImgSrc; - - return html` - - - - `; - }; - - return html` -
- - -
-
-
- ${msg("Page Thumbnail")} -
- ${renderPageThumbnail.bind(this)( - this.defaultThumbnailName == null - ? this.collection?.thumbnail?.path - : null, - )} -
- ${msg("Placeholder")} -
- ${thumbnail(Thumbnail.Cyan)} ${thumbnail(Thumbnail.Green)} - ${thumbnail(Thumbnail.Yellow)} ${thumbnail(Thumbnail.Orange)} -
-
-
- `; -} - -function renderPageThumbnail( - this: CollectionEdit, - initialPath?: string | null, -) { - const replaySource = `/api/orgs/${this.orgId}/collections/${this.collection!.id}/replay.json`; - // TODO Get query from replay-web-page embed - const query = queryString.stringify({ - source: replaySource, - customColl: this.collection!.id, - embed: "default", - noCache: 1, - noSandbox: 1, - }); - - const isSelected = this.defaultThumbnailName == null; - - this.thumbnailPreview?.thumbnailBlob - .then((value) => { - this.blobIsLoaded = !!value; - }) - .catch(() => { - this.blobIsLoaded = false; - }); - - const enabled = - (!!this.selectedSnapshot && this.blobIsLoaded) || !!initialPath; - - return html` - - `; -} diff --git a/frontend/src/features/collections/edit-dialog/sharing-section.ts b/frontend/src/features/collections/edit-dialog/sharing-section.ts index 1de86ab733..6bb7d5a28d 100644 --- a/frontend/src/features/collections/edit-dialog/sharing-section.ts +++ b/frontend/src/features/collections/edit-dialog/sharing-section.ts @@ -89,13 +89,15 @@ export class CollectionShareSettings extends BtrixElement {
${msg("Downloads")} - - +
; + url: Promise; + } + >(); + + #fuse?: Fuse; + + private get selectedThumbnailPageUrl() { + if (this.thumbnailName) return; + return this.thumbnailSource?.url; + } + + /** + * Get page URLs included in collection and determine whether to use fuzzy search or prefix search + */ + readonly urlCountsTask = new Task(this, { + task: async ([id], { signal }) => { + if (!id) return; + + const { items } = await this.getUrlCounts( + { id, pageSize: SEARCHABLE_MAX }, + signal, + ); + + // FIXME API doesn't currently return total so length instead + if (items.length < SEARCHABLE_MAX) { + if (this.#fuse) { + this.#fuse.setCollection(items); + } else { + this.#fuse = new Fuse(items, { + ...defaultFuseOptions, + shouldSort: true, + keys: ["url"], + }); + } + } + + return items; + }, + args: () => [this.collectionId] as const, + }); + + /** + * Make page-based thumbnail options from page URLs in collection, filtered by search string. + */ + private readonly optionsTask = new Task(this, { + task: async ([items, searchValue], { signal }) => { + if (!items) return; + + let options = items.slice(0, SEARCH_LIMIT); + + // Use fuzzy search if available + if (this.#fuse) { + if (searchValue) { + options = this.#fuse + .search(this.searchValue, { limit: SEARCH_LIMIT }) + .map(({ item }) => item); + } + } else { + // Use API URL prefix search + if (searchValue) { + const { items } = await this.getUrlCounts( + { + id: this.collectionId ?? "", + urlPrefix: searchValue, + pageSize: SEARCH_LIMIT, + }, + signal, + ); + + options = items; + } + } + + return options.map((opt) => { + const snapshot = opt.snapshots[opt.snapshots.length - 1]; + + return { + pageId: snapshot.pageId, + url: opt.url, + timestamp: snapshot.ts, + }; + }); + }, + args: () => [this.urlCountsTask.value, this.searchValue] as const, + }); + + /** + * Get page screenshots for page-based thumbail options. + * Depends on `this.rwp` context. + */ + private readonly screenshotsTask = new Task(this, { + task: async ([options, rwp], { signal }) => { + if (!options || !rwp) return this.#screenshots; + + options.forEach(({ pageId, timestamp, url }) => { + let thumbnail = this.#screenshots.get(pageId); + + if (!thumbnail) { + const blob = this.getBlob( + { collectionId: this.collectionId, rwp: this.rwp, timestamp, url }, + signal, + ); + + thumbnail = { + blob, + url: blob.then((v) => (v ? URL.createObjectURL(v) : undefined)), + }; + + // - Cache blob to use as upload payload + // - Cache object URL to revoke in component teardown + this.#screenshots.set(pageId, thumbnail); + } + }); + + return this.#screenshots; + }, + args: () => [this.optionsTask.value, this.rwp] as const, + }); + + /** + * Save thumbnail to collection. + */ + private readonly updateThumbnailTask = new Task(this, { + autoRun: false, + task: async ([option], { signal }) => { + if (!option) return; + + this.open = false; + + try { + if (typeof option === "string") { + await this.updateThumbnail({ defaultThumbnailName: option }, signal); + } else { + const screenshot = this.#screenshots.get(option.pageId); + + if (!screenshot) { + throw new Error("no screenshot"); + } + + const url = await screenshot.url; + + if (!url) { + throw new Error("no screenshot url"); + } + + this.nextThumbnailUrl = url; + + this.notify.toast({ + message: msg("Updating thumbnail..."), + variant: "info", + icon: "info-circle", + id: "collection-thumbnail-update-status", + }); + + await this.uploadThumbnail(option, signal); + await this.updateThumbnail({ defaultThumbnailName: null }, signal); + } + + this.notify.toast({ + message: msg("Thumbnail updated."), + variant: "success", + icon: "check2-circle", + id: "collection-thumbnail-update-status", + }); + + this.dispatchEvent(new CustomEvent("btrix-collection-saved")); + } catch (err) { + console.debug(err); + + this.notify.toast({ + message: msg("Sorry, couldn't update thumbnail at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "collection-thumbnail-update-status", + }); + } + }, + args: () => + [undefined] as readonly [PageSnapshotOption | Thumbnail | undefined], + }); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("thumbnailPath") && this.thumbnailPath) { + this.nextThumbnailUrl = undefined; + } + } + + disconnectedCallback(): void { + for (const screenshot of this.#screenshots.values()) { + void screenshot.url.then((url) => + url ? URL.revokeObjectURL(url) : null, + ); + } + } + + render() { + const isCrawler = this.appState.isCrawler; + const updating = this.updateThumbnailTask.status === TaskStatus.PENDING; + + return html` (this.open = true)} + @sl-after-show=${async () => { + await this.optionsTask.taskComplete; + + const pageUrl = this.selectedThumbnailPageUrl; + + if ( + !pageUrl || + !this.optionsTask.value?.some(({ url }) => url === pageUrl) + ) { + this.input?.focus(); + } + }} + @sl-hide=${() => (this.open = false)} + @sl-after-hide=${() => { + if (this.input) { + this.input.value = ""; + this.searchValue = ""; + } + }} + > +
+ name === this.thumbnailName, + )?.[1].path || + this.thumbnailPath, + )} + > + + ${when( + isCrawler, + () => html` + + + + `, + )} +
+ { + const { value } = e.detail.item; + + const defaultThumbnail = Object.entries( + CollectionThumbnail.Variants, + ).find(([_name, { path }]) => path === value); + + if (defaultThumbnail) { + void this.updateThumbnailTask.run([ + defaultThumbnail[0] as Thumbnail, + ]); + } else { + const option = this.optionsTask.value?.find( + ({ pageId }) => pageId === value, + ); + + if (!option) { + console.debug("no option"); + return; + } + + void this.updateThumbnailTask.run([option]); + } + }} + > + +
+ ${msg("Page Screenshot")} +
+
+
${this.renderSearch()}
+ +
`option-${pageId}`) + .join(" "), + )} + >
+ ${this.renderPages()} + + + ${msg("Default Thumbnails")} + + ${Object.entries(CollectionThumbnail.Variants).map(([name, variant]) => + this.renderDefaultOption({ + selected: this.thumbnailName === name, + ...variant, + }), + )} +
+
`; + } + + private renderSearch() { + return html`} + > + ${this.optionsTask.render({ + pending: () => html``, + complete: () => html``, + })} + `; + } + + private readonly renderSnapshotOption = ({ + pageId, + url, + timestamp, + }: PageSnapshotOption) => { + const selected = url === this.selectedThumbnailPageUrl; + const thumbnail = (url?: string) => + url + ? html`
+ +
+ +
+ +
+ + + + + +
+
+
` + : html`
+ + ${msg("No thumbnail")} +
`; + const asyncScreenshotUrl = this.screenshotsTask.value?.get(pageId)?.url; + const updating = this.updateThumbnailTask.status === TaskStatus.PENDING; + const isHomepage = url === this.homeUrl && timestamp === this.homeUrlTs; + + return html` updating || !path), + true, + )} + @keydown=${this.onOptionKeydown} + > + ${until( + asyncScreenshotUrl?.then(thumbnail), + html``, + )} + ${when( + isHomepage, + () => + html`${msg("Homepage")}`, + )} +
+ +
+ ${when( + timestamp, + (ts) => html` +
+ ${this.localize.date(ts)} +
+ `, + )} +
`; + }; + + private readonly renderDefaultOption = ({ + selected, + path, + label, + }: { + selected?: boolean; + path: string; + label: string; + }) => + html` + + ${msg("Browsertrix")} ${label} + `; + + private renderPages() { + const skeleton = () => + Array.from({ + length: Math.min(SEARCH_LIMIT, this.pageCount || SEARCH_LIMIT), + }).map( + () => html` + + + + + `, + ); + + const list = (options?: PageSnapshotOption[]) => { + if (!options) return skeleton(); + + if (!options.length) { + return html`
+ ${this.searchValue + ? msg("No matching pages found.") + : msg("No pages found.")} +
`; + } + + return repeat(options, ({ pageId }) => pageId, this.renderSnapshotOption); + }; + + return this.optionsTask.render({ + complete: list, + pending: () => list(this.optionsTask.value), + initial: skeleton, + }); + } + + private getFirstFocusable() { + if (!this.menu) { + console.debug("no this.menu"); + return false; + } + + const options = focusable(this.menu); + + if (options.length) { + return options[0]; + } + } + + private getLastFocusable() { + if (!this.menu) { + console.debug("no this.menu"); + return false; + } + + const options = focusable(this.menu); + + if (options.length) { + return options[options.length - 1]; + } + } + + private readonly onOptionKeydown = (e: KeyboardEvent) => { + const el = e.currentTarget as SlMenuItem; + + // Check if focus should return to search input + if ( + ((e.key === "ArrowDown" && el === this.getLastFocusable()) || + (e.key === "ArrowUp" && el === this.getFirstFocusable())) && + this.input + ) { + e.stopPropagation(); + this.input.focus(); + } + }; + + private readonly onSearchKeydown = (e: KeyboardEvent) => { + // Check if focus should move to options + switch (e.key) { + case "Tab": + case "ArrowDown": { + const focusable = this.getFirstFocusable(); + if (focusable) { + e.stopPropagation(); + focusable.focus(); + } + break; + } + case "ArrowUp": { + const focusable = this.getLastFocusable(); + if (focusable) { + e.stopPropagation(); + focusable.focus(); + } + break; + } + case "Space": + // Prevent closing dropdown + e.stopPropagation(); + break; + default: + break; + } + }; + + private readonly onSearchInput = debounce(300)(() => { + const value = this.input?.value.trim(); + + if (value) { + if (this.#fuse || value.startsWith("http")) { + this.searchValue = value; + } else { + this.searchValue = `https://${value}`; + + if (this.input) { + this.input.value = this.searchValue; + } + } + } else { + this.searchValue = ""; + } + }); + + private async getUrlCounts( + { id, ...params }: { id: string; urlPrefix?: string } & APIPaginationQuery, + signal: AbortSignal, + ) { + const query = queryString.stringify({ ...params }); + + return this.api.fetch>( + `/orgs/${this.orgId}/collections/${id}/pageUrlCounts?${query}`, + { signal }, + ); + } + + private readonly getBlob = getThumbnailBlob; + + private async uploadThumbnail( + { pageId, url, timestamp }: PageSnapshotOption, + signal: AbortSignal, + ) { + const screenshot = this.#screenshots.get(pageId); + + if (!screenshot) { + throw new Error("no screenshot"); + } + + const blob = await screenshot.blob; + + if (!blob) { + throw new Error("no screenshot blob"); + } + + const fileName = `page-thumbnail_${pageId}.jpeg`; + const file = new File([blob], fileName, { + type: blob.type, + }); + + const searchParams = new URLSearchParams({ + filename: fileName, + sourceUrl: url, + sourceTs: timestamp, + sourcePageId: pageId, + }); + + return this.api.upload( + `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?${searchParams.toString()}`, + file, + signal, + ); + } + + private async updateThumbnail( + { + defaultThumbnailName, + }: { + defaultThumbnailName: string | null; + }, + signal: AbortSignal, + ) { + return this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/collections/${this.collectionId}`, + { + method: "PATCH", + body: JSON.stringify({ defaultThumbnailName }), + signal, + }, + ); + } +} diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts index bfb7ddffd8..140c068153 100644 --- a/frontend/src/features/collections/share-collection.ts +++ b/frontend/src/features/collections/share-collection.ts @@ -68,31 +68,27 @@ export class ShareCollection extends BtrixElement { return html`
- ${when( - this.context === "public", - () => html` - this.shareLink} - content=${msg("Copy Public Link")} - @click=${() => { - void this.clipboardController.copy(this.shareLink); - - if ( - this.collection && - this.collection.access === CollectionAccess.Public - ) { - track(AnalyticsTrackEvent.CopyShareCollectionLink, { - org_slug: this.orgSlug, - collection_slug: this.collection.slug, - logged_in: !!this.authState, - }); - } - }} - > - `, - )} + this.shareLink} + content=${msg("Copy Shareable Link")} + @click=${() => { + void this.clipboardController.copy(this.shareLink); + + if ( + this.context === "public" && + this.collection && + this.collection.access === CollectionAccess.Public + ) { + track(AnalyticsTrackEvent.CopyShareCollectionLink, { + org_slug: this.orgSlug, + collection_slug: this.collection.slug, + logged_in: !!this.authState, + }); + } + }} + > = { [Tab.Replay]: { icon: { name: "replaywebpage", library: "app" }, - text: msg("Replay"), + text: msg("Browse Collection"), }, [Tab.Items]: { icon: { name: "list-ul", library: "default" }, @@ -186,6 +196,192 @@ export class CollectionDetail extends BtrixElement { return this.appState.isCrawler; } + /** + * Get page ID from URL and timestamp + */ + private readonly replayCurrentPage = new Task(this, { + task: async ([replayCurrentView], { signal }) => { + if (!replayCurrentView) return; + + const { url, ts } = replayCurrentView; + const { items } = await this.getPages( + { url, sortBy: "ts", sortDirection: SortDirection.Descending }, + signal, + ); + let page: PageSnapshot | undefined = items[0]; + + if (ts) { + page = items.find((page) => formatRwpTimestamp(page.ts) === ts); + } + + return page || null; + }, + args: () => [this.replayCurrentView] as const, + }); + + /** + * Revert thumbnail change + */ + private readonly revertThumbnailTask = new Task(this, { + task: async ([oldThumbnail, oldDefaultThumbnailName], { signal }) => { + try { + if (oldDefaultThumbnailName) { + await this.updateThumbnail( + { + defaultThumbnailName: oldDefaultThumbnailName, + }, + signal, + ); + } else { + if (oldThumbnail) { + this.notify.toast({ + message: msg("Reverting thumbnail..."), + variant: "info", + icon: "info-circle", + id: "update", + }); + + await this.uploadThumbnail( + { + url: oldThumbnail.url, + timestamp: oldThumbnail.urlTs, + pageId: oldThumbnail.urlPageId, + }, + signal, + ); + } + await this.updateThumbnail( + { + defaultThumbnailName: oldThumbnail ? null : DEFAULT_THUMBNAIL, + }, + signal, + ); + } + + this.notify.toast({ + message: msg("Thumbnail updated."), + variant: "success", + icon: "check2-circle", + id: "update", + }); + + await this.fetchCollection(); + } catch { + this.notify.toast({ + message: msg("Sorry, couldn’t revert thumbnail at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "update", + }); + } + }, + args: () => + [undefined, undefined] as readonly [ + Collection["thumbnailSource"] | undefined, + Collection["defaultThumbnailName"] | undefined, + ], + autoRun: false, + }); + + /** + * Update Replay/collection initial view + */ + private readonly updateHomepageTask = new Task(this, { + task: async ([page], { signal }) => { + this.revertThumbnailTask.abort(); + + try { + const oldThumbnail = this.collection?.thumbnailSource; + const oldDefaultThumbnailName = this.collection?.defaultThumbnailName; + let thumbnailUpdated = false; + + await this.updateHomepage({ pageId: page?.id ?? null }, signal); + + if (page) { + this.notify.toast({ + message: msg("Updating homepage..."), + variant: "info", + icon: "info-circle", + id: "update", + }); + + try { + await this.uploadThumbnail( + { url: page.url, timestamp: page.ts, pageId: page.id }, + signal, + ); + await this.updateThumbnail({ defaultThumbnailName: null }, signal); + + thumbnailUpdated = true; + } catch (err) { + console.debug(err); + } + } + + // Optimistic update + if (this.collection) { + this.collection = { + ...this.collection, + homeUrl: page?.url || null, + homeUrlTs: page?.ts || null, + homeUrlPageId: null, + }; + } + + if (thumbnailUpdated) { + const undo = () => + void this.revertThumbnailTask.run([ + oldThumbnail, + oldDefaultThumbnailName, + ]); + const undoButton = html``; + this.notify.toast({ + title: msg("Homepage updated"), + message: html`${msg("Thumbnail updated to match.")} ${undoButton}`, + variant: "success", + icon: "check2-circle", + duration: 10000, + id: "update", + }); + } else { + this.notify.toast({ + message: msg("Homepage updated."), + variant: "success", + icon: "check2-circle", + id: "update", + }); + } + + await this.fetchCollection(); + } catch (err) { + if (isApiError(err) && err.details === "invalid_collection_page") { + this.notify.toast({ + message: msg("Please choose another homepage."), + variant: "warning", + icon: "exclamation-triangle", + id: "update", + }); + } else { + console.debug(err); + + this.notify.toast({ + message: msg("Sorry, couldn’t update homepage at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "update", + }); + } + } + }, + args: () => [undefined] as readonly [PageSnapshot | undefined], + autoRun: false, + }); + disconnectedCallback(): void { window.clearTimeout(this.timerId); super.disconnectedCallback(); @@ -195,6 +391,7 @@ export class CollectionDetail extends BtrixElement { changedProperties: PropertyValues & Map, ) { if (changedProperties.has("collectionId")) { + this.replayEmbed = undefined; void this.fetchCollection(); void this.fetchArchivedItems({ page: parsePage(new URLSearchParams(location.search).get("page")), @@ -226,34 +423,57 @@ export class CollectionDetail extends BtrixElement { }, 200); } } + + if (changedProperties.has("collection") && this.collection) { + const prevCollection = changedProperties.get("collection"); + + if ( + prevCollection && + (prevCollection as Collection).modified !== this.collection.modified + ) { + void this.refreshReplay(); + } + } } render() { const collection_name = html`${this.collection?.name}`; - const caption = (text?: Collection["caption"]) => { - if (text) { - return html`
- ${richText(text)} -
`; - } - }; + const showCaption = this.isCrawler || this.collection?.caption; return html`
${this.renderBreadcrumbs()}
+
+ ${when( + this.collection, + this.renderThumbnail, + () => + html``, + )} +
+
+
${pageTitle( - this.isCrawler - ? html` { - e.stopPropagation(); - - const { value } = e.detail; - - this.slugPreview = value ? slugifyStrict(value) : ""; - }} - @btrix-change=${(e: EditableTextFieldChangeEvent) => { - e.stopPropagation(); - - const { value } = e.detail; - - if (value === this.collection?.name) { - this.slugPreview = ""; - } - - void this.updateName(value); - }} - extraWidth=${24} - > - - ` - : this.collection?.name, + when(this.collection, this.renderName), tw`mb-2 h-6 w-60`, tw`grid`, )}
${this.renderAccessDetails()}
-
- ${this.isCrawler - ? when( - this.collection, - (col) => - html`) => { - void this.updateSummary(e.detail.value); - }} - extraWidth=${24} - > - - `, - ) - : caption(this.collection?.caption)} -
+ ${showCaption + ? html`
+ ${this.isCrawler + ? when( + this.collection, + (col) => + html` { + void this.updateSummary(e.detail.value); + }} + >`, + ) + : this.collection?.caption + ? this.renderCaption(this.collection.caption) + : nothing} +
` + : nothing}
{ - this.openDialogName = "replaySettings"; - }} - title=${ifDefined( - this.isRwpLoaded - ? undefined - : msg("Please wait for replay load"), - )} - ?disabled=${!this.isRwpLoaded || - this.collection.runningUpdatesCount} - > - ${this.isRwpLoaded && - !this.collection.runningUpdatesCount - ? html`` - : html``} - ${msg("Set Initial View")} - + + ${ + // TODO Enable with https://github.com/webrecorder/replayweb.page/tree/target-v2.5.0 + "" + // + // + // + // + // + } + + + ${msg("Set Homepage")} + + { + if (this.replayCurrentPage.value) { + void this.updateHomepageTask.run([ + this.replayCurrentPage.value, + ]); + } + }} + > + + ${msg("Current Page")} + + void this.updateHomepageTask.run()} + > + + ${msg("Page List")} + + + + ` - : this.collection?.runningUpdatesCount - ? html`` - : nothing, + : nothing, ], [ Tab.Items, @@ -409,8 +617,19 @@ export class CollectionDetail extends BtrixElement { ]), )}
+ +
+ ${when(this.collection, this.guardedRenderReplay, this.renderSpinner)} +
+ ${choose(this.collectionTab, [ - [Tab.Replay, () => guard([this.collection], this.renderReplay)], [ Tab.Items, () => guard([this.archivedItems], this.renderArchivedItems), @@ -570,52 +789,28 @@ export class CollectionDetail extends BtrixElement { this.collection, )} @sl-hide=${() => this.editing.setValue(null)} - @btrix-collection-saved=${() => { - this.refreshReplay(); - void this.fetchCollection(); + @btrix-collection-saved=${async () => { void this.fetchArchivedItems(); - }} - > - + await this.fetchCollection(); + await this.updateComplete; - { - // Don't do full refresh of rwp so that rwp-url-change fires - this.isRwpLoaded = false; - - void this.fetchCollection(); + // Re-render collection thumbnails since they're dependent items and replay + void this.selectCollectionThumbnail?.urlCountsTask.run(); }} - @sl-hide=${async () => (this.openDialogName = undefined)} - collectionId=${this.collectionId} - .collection=${this.collection} - ?replayLoaded=${this.isRwpLoaded} > - + (this.openDialogName = undefined)} @btrix-collection-saved=${() => { - this.refreshReplay(); // TODO maybe we can return the updated collection from the update endpoint, and avoid an extra fetch? void this.fetchCollection(); }} - @btrix-collection-edit-dialog-tab-change=${( - e: CustomEvent, - ) => { - this.editTab = e.detail; - }} @btrix-change=${() => { - // Don't do full refresh of rwp so that rwp-url-change fires - this.isRwpLoaded = false; - void this.fetchCollection(); }} - .replayWebPage=${this.replayEmbed} - ?replayLoaded=${this.isRwpLoaded} > ${createIndexDialog({ @@ -639,6 +834,68 @@ export class CollectionDetail extends BtrixElement { `; } + private readonly renderThumbnail = (collection: Collection) => { + return html` + { + void this.fetchCollection(); + }} + > + `; + }; + + private readonly renderName = (collection: Collection) => { + if (!this.isCrawler) { + return html`
${collection.name}
`; + } + + return html` { + e.stopPropagation(); + + const { value } = e.detail; + + this.slugPreview = value ? slugifyStrict(value) : ""; + }} + @btrix-change=${(e: EditableTextFieldChangeEvent) => { + e.stopPropagation(); + + const value = e.detail.value.trim(); + + if (value === this.collection?.name) { + this.slugPreview = ""; + } + + void this.updateName(value); + }} + extraWidth=${24} + > + + + + `; + }; + private readonly renderAccessDetails = () => { if (!this.collection) { return html``; @@ -657,31 +914,31 @@ export class CollectionDetail extends BtrixElement { const namespacedPath = `${RouteNamespace.PublicOrgs}/${this.viewState?.params.slug}/${OrgTab.Collections}`; const slugPreview = this.slugPreview || this.collection?.slug || ""; const link = new URL(`${baseUrl}/${namespacedPath}/${slugPreview}`).href; - - return html` - ${toShortUrl(baseUrl, null)}/.../${slugPreview} + ${toShortUrl(baseUrl, null)}/.../${slugPreview} + `; + + return html` ${this.slugPreview + ? displayUrl + : html` - - ${this.slugPreview - ? nothing - : html` - - - - - `}`; + ${displayUrl} + + `}`; }; return html`
@@ -690,15 +947,19 @@ export class CollectionDetail extends BtrixElement {
`; }; - private readonly renderCaption = (text: string) => - richText(text, { - linkClass: tw`text-cyan-500 transition-colors hover:text-cyan-600`, - }); + private readonly renderCaption = (text: string) => { + return html`${richText(text, { + linkClass: tw`text-cyan-500 transition-colors hover:text-cyan-600`, + })}`; + }; - private refreshReplay() { + private async refreshReplay() { if (this.replayEmbed) { try { - void this.replayEmbed.fullReload(); + await this.replayEmbed.fullReload(); } catch (e) { console.warn("Full reload not available in RWP"); } @@ -763,87 +1024,67 @@ export class CollectionDetail extends BtrixElement { private readonly renderActions = () => { const authToken = this.authState?.headers.Authorization.split(" ")[1]; + const showShare = this.collection?.crawlCount; return html` - - { - this.openDialogName = "edit"; - this.editTab = "sharing"; - }} - > - - - { - this.openDialogName = "edit"; - this.editTab = "general"; - }} - > - - - - - ${msg("Actions")} + ${showShare + ? html` + ${when( + this.collection, + (collection) => html` +
+
+ ${SelectCollectionAccess.Options[collection.access].label} +
+

+ ${SelectCollectionAccess.Options[collection.access].detail} +

+
+ `, + )} + { + this.openDialogName = "edit"; + }} + > + + ${msg("Share")} + +
` + : nothing} + + ${when( + this.collection, + () => + html` + ${showShare + ? html`` + : msg("Actions")} + `, + () => + html``, + )} - { - // replay-web-page needs to be available in order to configure start page - if (this.collectionTab !== Tab.Replay) { - this.navigate.to( - `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.Replay}`, - ); - await this.updateComplete; - } - - this.openDialogName = "edit"; - this.editTab = "sharing"; - }} - > - - ${msg("Share Collection")} - - { - // replay-web-page needs to be available in order to configure start page - if (this.collectionTab !== Tab.Replay) { - this.navigate.to( - `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.Replay}`, - ); - await this.updateComplete; - } - - this.openDialogName = "edit"; - this.editTab = "general"; - }} - > - - ${msg("Edit Collection Settings")} - - ${when( - this.collection?.crawlCount, - () => html` - { - this.openDialogName = "replaySettings"; + this.openDialogName = "edit"; }} - ?disabled=${!this.isRwpLoaded} > - ${this.isRwpLoaded - ? html`` - : html``} - ${msg("Set Initial View")} - - `, - () => - this.collection?.runningUpdatesCount - ? html`` - : nothing, - )} + + ${msg("Share Collection")} + `} { this.navigate.to( @@ -1303,42 +1544,67 @@ export class CollectionDetail extends BtrixElement { `; - private readonly renderReplay = () => { - if (!this.collection) { - return this.renderSpinner(); - } - if (!this.collection.crawlCount) { - return this.renderEmptyState(); - } + private readonly guardedRenderReplay = (collection: Collection) => { + return guard([collection.crawlCount], () => + collection.crawlCount + ? guard([this.collectionId], this.renderReplay) + : this.renderEmptyState(), + ); + }; + private readonly renderReplay = () => { const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`; const headers = this.authState?.headers; const config = JSON.stringify({ headers }); - return html`
+ return html` { + hideOffscreen="true" + @rwp-page-loading=${(e: RwpPageLoadingEvent) => { + if ( + !e.detail.loading && + "replayNotFoundError" in e.detail && + e.detail.replayNotFoundError + ) { + this.replayCurrentView = undefined; + } + }} + @rwp-url-change=${(e: RwpUrlChangeEvent) => { + if (!this.replayEmbed) { + this.replayEmbed = e.currentTarget as ReplayWebPage; + } if (!this.isRwpLoaded) { this.isRwpLoaded = true; } - if (this.rwpDoFullReload && this.replayEmbed) { + if (this.rwpDoFullReload) { void this.replayEmbed.fullReload(); this.rwpDoFullReload = false; } + + const { url, ts } = e.detail; + + if ( + !( + "replayNotFoundError" in e.detail && e.detail.replayNotFoundError + ) && + url + ) { + this.replayCurrentView = { url, ts }; + } }} > -
`; + `; }; private readonly renderSpinner = () => html` @@ -1349,6 +1615,29 @@ export class CollectionDetail extends BtrixElement {
`; + /** + * Navigate RWP to collection home URL. + */ + private readonly goToHomepage = async () => { + if (!this.collection) { + console.debug("no this.collection"); + return; + } + + if (!this.replayEmbed) { + console.debug("no this.replayEmbed"); + return; + } + + // @ts-expect-error Not used for now, enable with https://github.com/webrecorder/replayweb.page/tree/target-v2.5.0 + this.replayEmbed.mainElement?.navigateReplayTo( + this.collection.homeUrl || "pages", + this.collection.homeUrlTs + ? { ts: formatRwpTimestamp(this.collection.homeUrlTs) || "" } + : undefined, + ); + }; + private readonly confirmDelete = () => { this.openDialogName = "delete"; }; @@ -1511,7 +1800,6 @@ export class CollectionDetail extends BtrixElement { icon: "check2-circle", id: "update", }); - this.refreshReplay(); void this.fetchCollection(); void this.fetchArchivedItems({ // Update page if last item @@ -1714,7 +2002,6 @@ export class CollectionDetail extends BtrixElement { method: "POST", }, ); - this.refreshReplay(); await this.fetchCollection(); this.notify.toast({ @@ -1746,7 +2033,6 @@ export class CollectionDetail extends BtrixElement { body: JSON.stringify(params), }, ); - this.refreshReplay(); await this.fetchCollection(); this.notify.toast({ @@ -1768,4 +2054,89 @@ export class CollectionDetail extends BtrixElement { }); } } + + private async getPages( + params: { url?: string; ts?: string } & APISortQuery & APIPaginationQuery, + signal: AbortSignal, + ) { + const query = queryString.stringify({ ...params }); + + return this.api.fetch>( + `/orgs/${this.orgId}/collections/${this.collectionId}/pages?${query}`, + { signal }, + ); + } + + private async uploadThumbnail( + { + url, + timestamp, + pageId, + }: { url: string; timestamp: string; pageId: string }, + signal: AbortSignal, + ) { + const blob = await getThumbnailBlob( + { + collectionId: this.collectionId, + rwp: this.replayEmbed, + url, + timestamp, + }, + signal, + ); + + if (!blob) { + throw new Error("thumbnail not found"); + } + + const fileName = `page-thumbnail_${pageId}.jpeg`; + const file = new File([blob], fileName, { + type: blob.type, + }); + + const searchParams = new URLSearchParams({ + filename: fileName, + sourceUrl: url, + sourceTs: timestamp, + sourcePageId: pageId, + }); + + return this.api.upload( + `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?${searchParams.toString()}`, + file, + signal, + ); + } + + private async updateThumbnail( + { + defaultThumbnailName, + }: { + defaultThumbnailName: string | null; + }, + signal: AbortSignal, + ) { + return this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/collections/${this.collectionId}`, + { + method: "PATCH", + body: JSON.stringify({ defaultThumbnailName }), + signal, + }, + ); + } + + private async updateHomepage( + params: { pageId?: string | null; url?: string; ts?: string }, + signal: AbortSignal, + ) { + return this.api.fetch( + `/orgs/${this.orgId}/collections/${this.collectionId}/home-url`, + { + method: "POST", + body: JSON.stringify(params), + signal, + }, + ); + } } diff --git a/frontend/src/pages/org/collection-detail/context/collection-rwp.ts b/frontend/src/pages/org/collection-detail/context/collection-rwp.ts new file mode 100644 index 0000000000..5fc93e0d2a --- /dev/null +++ b/frontend/src/pages/org/collection-detail/context/collection-rwp.ts @@ -0,0 +1,11 @@ +/** + * Make collection ReplayWeb.Page iframe available to fetch objects like screenshots. + */ + +import { createContext } from "@lit/context"; +import type { ReplayWebPage } from "replaywebpage"; + +export type CollectionRwpContext = ReplayWebPage | null | undefined; + +export const collectionRwpContext = + createContext("collection-rwp"); diff --git a/frontend/src/pages/org/collection-detail/types.ts b/frontend/src/pages/org/collection-detail/types.ts index f21f768700..8da1635672 100644 --- a/frontend/src/pages/org/collection-detail/types.ts +++ b/frontend/src/pages/org/collection-detail/types.ts @@ -8,7 +8,6 @@ export enum Tab { export type Dialog = | "delete" | "edit" - | "replaySettings" | "removeItem" | "createIndex" | "purgeIndex" diff --git a/frontend/src/pages/org/collection-detail/utils/getThumbnailBlob.ts b/frontend/src/pages/org/collection-detail/utils/getThumbnailBlob.ts new file mode 100644 index 0000000000..402188f28d --- /dev/null +++ b/frontend/src/pages/org/collection-detail/utils/getThumbnailBlob.ts @@ -0,0 +1,36 @@ +import type { CollectionRwpContext } from "../context/collection-rwp"; + +import { formatRwpTimestamp } from "@/utils/replay"; + +/** + * Query thumbnail from `` embed + */ +export const getThumbnailBlob = async ( + { + collectionId, + rwp, + url, + timestamp, + }: { + collectionId?: string; + rwp?: CollectionRwpContext; + url: string; + timestamp: string; + }, + signal: AbortSignal, +) => { + if (!rwp) { + console.debug("no rwp"); + return; + } + const resp = await rwp.shadowRoot + ?.querySelector("iframe") + ?.contentWindow?.fetch( + `/replay/w/${collectionId}/${formatRwpTimestamp(timestamp)}id_/urn:thumbnail:${url}`, + { signal }, + ); + + if (resp?.status === 200) { + return await resp.blob(); + } +}; diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index ffb232a03c..f511b04383 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -720,8 +720,8 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { void this.manageCollection(col, "edit")} > - - ${msg("Edit Collection Settings")} + + ${msg("Share Collection")} `, )} diff --git a/frontend/src/replayWebPage.d.ts b/frontend/src/replayWebPage.d.ts index d783d6ae32..949bd575e9 100644 --- a/frontend/src/replayWebPage.d.ts +++ b/frontend/src/replayWebPage.d.ts @@ -1,4 +1,4 @@ -import type { Embed as ReplayWebPage } from "replaywebpage"; +import type { ReplayWebPage } from "replaywebpage"; type RwpUrlChangeEvent = CustomEvent<{ type: "urlchange"; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index ba5f5e1251..3831b15c2a 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -303,8 +303,9 @@ @apply part-[base]:bg-white part-[base]:text-neutral-700 part-[base]:hover:bg-cyan-50/50 part-[base]:hover:text-cyan-700 part-[base]:focus:text-cyan-700 part-[base]:focus-visible:bg-cyan-50/50; } - sl-option[aria-selected="true"] { - @apply part-[base]:bg-cyan-50 part-[base]:text-cyan-700; + sl-option[aria-selected="true"], + sl-menu-item[aria-selected="true"] { + @apply part-[base]:bg-cyan-50 part-[base]:text-cyan-600; } /* Add menu item variants */ diff --git a/frontend/src/types/page.ts b/frontend/src/types/page.ts new file mode 100644 index 0000000000..8f3642a016 --- /dev/null +++ b/frontend/src/types/page.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const timestampSchema = z.string(); // TODO timestamp +const statusSchema = z.number().int(); // TODO HTTP status codes + +export const pageSnapshotSchema = z.object({ + id: z.string(), + oid: z.string(), + crawl_id: z.string(), + url: z.string(), + title: z.string(), + ts: timestampSchema, + loadState: z.number().int(), + status: statusSchema, + mime: z.string(), + filename: z.string(), + depth: z.number().int(), + favIconUrl: z.string(), + isSeed: z.boolean(), + userid: z.string(), + modified: z.string(), + approved: z.boolean(), + notes: z.array(z.string()), + isFile: z.boolean(), + isError: z.boolean(), +}); +export type PageSnapshot = z.infer; + +export type PageSnapshotSortFields = Pick; + +export const pageUrlCountsSchema = z.object({ + url: z.string(), + count: z.number().int(), + snapshots: z.array( + z.object({ + pageId: z.string(), + ts: timestampSchema, + status: statusSchema, + }), + ), +}); +export type PageUrlCount = z.infer; From de86c3ca15c2cfea262eb7cd252b9fc050de057d Mon Sep 17 00:00:00 2001 From: sua yoo Date: Fri, 19 Jun 2026 10:56:46 -0700 Subject: [PATCH 3/3] feat: Allow editing collection in public page view (#3380) Allows users to update collection name, summary, thumbnail, and access from the shareable/public view. --- .../collections/collection-create-dialog.ts | 16 +- .../collections/collection-edit-dialog.ts | 7 +- .../collections/collection-page-header.ts | 407 +++++++++++++++ .../collections/collection-thumbnail.ts | 4 + .../edit-dialog/helpers/check-changed.ts | 15 +- .../edit-dialog/helpers/submit-task.ts | 1 + .../edit-dialog/sharing-section.ts | 8 +- .../select-collection-thumbnail.ts | 42 +- .../features/collections/share-collection.ts | 56 +- frontend/src/index.ts | 2 +- frontend/src/layouts/page.ts | 19 +- frontend/src/layouts/pageHeader.ts | 16 +- frontend/src/pages/collections/collection.ts | 191 +++++-- .../collection-detail/collection-detail.ts | 489 ++++-------------- .../src/pages/org/collection-detail/types.ts | 6 + frontend/src/pages/org/collections-list.ts | 2 +- frontend/src/pages/org/dashboard.ts | 2 +- frontend/src/pages/org/index.ts | 6 +- frontend/src/types/collection.ts | 2 - 19 files changed, 781 insertions(+), 510 deletions(-) create mode 100644 frontend/src/features/collections/collection-page-header.ts diff --git a/frontend/src/features/collections/collection-create-dialog.ts b/frontend/src/features/collections/collection-create-dialog.ts index be6fa8587d..f4e4a2165c 100644 --- a/frontend/src/features/collections/collection-create-dialog.ts +++ b/frontend/src/features/collections/collection-create-dialog.ts @@ -20,6 +20,7 @@ import { DEFAULT_THUMBNAIL } from "./collection-thumbnail"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; import type { SelectCollectionAccess } from "@/features/collections/select-collection-access"; +import type { CollectionSavedEvent } from "@/pages/org/collection-detail/types"; import { alerts } from "@/strings/collections/alerts"; import { COLLECTION_CAPTION_MAX_LENGTH, @@ -31,10 +32,6 @@ import { isApiError } from "@/utils/api"; import { formValidator, maxLengthValidator } from "@/utils/form"; import slugifyStrict from "@/utils/slugify"; -export type CollectionSavedEvent = CustomEvent<{ - id: string; -}>; - /** * @fires btrix-collection-saved CollectionSavedEvent Fires */ @@ -247,11 +244,14 @@ export class CollectionCreateDialog extends BtrixElement { }); this.dispatchEvent( - new CustomEvent("btrix-collection-saved", { - detail: { - id: data.id, + new CustomEvent( + "btrix-collection-saved", + { + detail: { + id: data.id, + }, }, - }) as CollectionSavedEvent, + ), ); this.notify.toast({ message: msg(str`Created "${data.name || name}" collection`), diff --git a/frontend/src/features/collections/collection-edit-dialog.ts b/frontend/src/features/collections/collection-edit-dialog.ts index db73ab4578..70b77ffd0e 100644 --- a/frontend/src/features/collections/collection-edit-dialog.ts +++ b/frontend/src/features/collections/collection-edit-dialog.ts @@ -21,6 +21,7 @@ import { COLLECTION_CAPTION_MAX_LENGTH, COLLECTION_NAME_MAX_LENGTH, type Collection, + type PublicCollection, } from "@/types/collection"; import { maxLengthValidator, type MaxLengthValidator } from "@/utils/form"; @@ -28,10 +29,6 @@ type Tab = "general" | "sharing"; export type { Tab as EditDialogTab }; -export type CollectionSavedEvent = CustomEvent<{ - id: string; -}>; - export const validateNameMax = maxLengthValidator(COLLECTION_NAME_MAX_LENGTH); export const validateCaptionMax = maxLengthValidator( COLLECTION_CAPTION_MAX_LENGTH, @@ -44,7 +41,7 @@ export const validateCaptionMax = maxLengthValidator( @localized() export class CollectionEdit extends BtrixElement { @property({ type: Object }) - collection?: Collection; + collection?: Collection | PublicCollection; /** For contexts where we don't have the full collection object already - * Will cause this to fetch the collection internally, so avoid if there's diff --git a/frontend/src/features/collections/collection-page-header.ts b/frontend/src/features/collections/collection-page-header.ts new file mode 100644 index 0000000000..3ef57eff4d --- /dev/null +++ b/frontend/src/features/collections/collection-page-header.ts @@ -0,0 +1,407 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; +import { html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { SelectCollectionAccess } from "./select-collection-access"; +import type { SelectCollectionThumbnail } from "./select-collection-thumbnail"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { EditableTextBoxChangeEvent } from "@/components/ui/editable-text-box"; +import type { + EditableTextFieldChangeEvent, + EditableTextFieldInputEvent, +} from "@/components/ui/editable-text-field"; +import { viewStateContext, type ViewStateContext } from "@/context/view-state"; +import { pageTitle } from "@/layouts/pageHeader"; +import type { CollectionSavedEvent } from "@/pages/org/collection-detail/types"; +import { OrgTab, RouteNamespace } from "@/routes"; +import { + COLLECTION_CAPTION_MAX_LENGTH, + COLLECTION_NAME_MAX_LENGTH, + CollectionAccess, + type Collection, + type PublicCollection, +} from "@/types/collection"; +import { isNotEqual } from "@/utils/is-not-equal"; +import { richText } from "@/utils/rich-text"; +import slugifyStrict from "@/utils/slugify"; +import { tw } from "@/utils/tailwind"; +import { toShortUrl } from "@/utils/url-helpers"; + +/** + * @fires btrix-collection-saved + */ +@customElement("btrix-collection-page-header") +@localized() +export class CollectionPageHeader extends BtrixElement { + @property({ type: String }) + collectionId = ""; + + @property({ type: String }) + collectionName?: string; + + @property({ type: Number }) + collectionSize?: number; + + @property({ type: String }) + slug?: string; + + @property({ type: String }) + caption?: string; + + @property({ type: String }) + access?: CollectionAccess; + + @property({ type: Boolean }) + allowPublicDownload?: boolean; + + @property({ type: String }) + homeUrl?: string; + + @property({ type: String }) + homeUrlTs?: string; + + @property({ type: String }) + thumbnailName?: string; + + @property({ type: String }) + thumbnailPath?: string; + + @property({ type: Object, hasChanged: isNotEqual }) + thumbnailSource?: PublicCollection["thumbnailSource"]; + + @property({ type: Number }) + pageCount?: number; + + @property({ type: Boolean }) + loading?: boolean; + + @property({ type: Boolean }) + canEdit = false; + + @property({ type: String }) + context: "private" | "public" = "public"; + + @state() + private slugPreview = ""; + + @consume({ context: viewStateContext }) + viewState?: ViewStateContext; + + @query("btrix-select-collection-thumbnail") + private readonly selectCollectionThumbnail?: SelectCollectionThumbnail | null; + + refresh() { + // Re-render collection thumbnails since they're dependent items and replay + void this.selectCollectionThumbnail?.urlCountsTask.run(); + } + + render() { + const canEdit = this.canEdit; + const showAccess = this.context === "private" || this.slugPreview; + const showCaption = canEdit || this.caption; + + return html`
+
+
+ ${this.loading + ? html`` + : this.renderThumbnail()} +
+
+
+
+ ${pageTitle( + when(this.collectionName, this.renderName), + tw`mb-2 h-6 w-60`, + clsx(tw`grid`, canEdit ? tw`mt-0.5` : tw`min-h-10 items-center`), + )} +
+ ${showAccess + ? html`
${this.renderAccessDetails()}
` + : nothing} +
+ ${showCaption + ? html`
+ ${canEdit + ? this.loading + ? html`` + : html` { + void this.updateSummary(e.detail.value); + }} + >` + : this.caption + ? this.renderCaption(this.caption) + : nothing} +
` + : nothing} + +
+ { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("btrix-collection-saved")); + }} + > + ${when(canEdit, () => html``)} +
+
`; + } + + private readonly renderThumbnail = () => { + return html` + + `; + }; + + private readonly renderName = (name: string) => { + if (!this.canEdit) { + return html`
${name}
`; + } + + return html` { + e.stopPropagation(); + + const { value } = e.detail; + + this.slugPreview = value ? slugifyStrict(value) : ""; + }} + @btrix-change=${(e: EditableTextFieldChangeEvent) => { + e.stopPropagation(); + + const value = e.detail.value.trim(); + + if (value === name) { + this.slugPreview = ""; + } + + void this.updateName(value); + }} + extraWidth=${24} + > + + + + `; + }; + + private readonly renderAccessDetails = () => { + if (!this.access) { + return html``; + } + + const badge = html` + + ${SelectCollectionAccess.Options[this.access].label} + `; + + const publicLink = () => { + const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + const namespacedPath = `${RouteNamespace.PublicOrgs}/${this.orgSlugState}/${OrgTab.Collections}`; + const slugPreview = this.slugPreview || this.slug || ""; + const link = new URL(`${baseUrl}/${namespacedPath}/${slugPreview}`).href; + const displayUrl = html` + ${toShortUrl(baseUrl, null)}/.../${slugPreview} + `; + + return html` ${this.slugPreview + ? displayUrl + : html` + ${displayUrl} + + `}`; + }; + + return html`
+ ${badge} ${when(this.access !== CollectionAccess.Private, publicLink)} +
`; + }; + + private readonly renderCaption = (text: string) => { + return html`${richText(text, { + linkClass: tw`text-cyan-500 transition-colors hover:text-cyan-600`, + })}`; + }; + + private async updateName(name: string) { + if (name === this.collectionName) { + return; + } + + try { + await this.api.fetch( + `/orgs/${this.orgId}/collections/${this.collectionId}`, + { + method: "PATCH", + body: JSON.stringify({ + name, + slug: slugifyStrict(name), + }), + }, + ); + + this.notify.toast({ + message: msg("Name updated."), + variant: "success", + icon: "check2-circle", + id: "update", + }); + + this.dispatchEvent( + new CustomEvent( + "btrix-collection-saved", + { + detail: { + id: this.collectionId, + name, + slug: this.slugPreview || this.slug || "", + }, + }, + ), + ); + + this.slugPreview = ""; + } catch (err) { + console.debug(err); + + this.notify.toast({ + message: msg("Sorry, couldn’t save collection name at this time."), + variant: "danger", + icon: "exclamation-octagon", + }); + } + } + + private async updateSummary(caption: string) { + caption = caption.trim(); + if (caption === this.caption) return; + try { + await this.api.fetch( + `/orgs/${this.orgId}/collections/${this.collectionId}`, + { + method: "PATCH", + body: JSON.stringify({ + caption, + }), + }, + ); + + this.notify.toast({ + message: msg("Summary updated."), + variant: "success", + icon: "check2-circle", + id: "update", + }); + + this.dispatchEvent( + new CustomEvent( + "btrix-collection-saved", + { + detail: { id: this.collectionId, caption }, + }, + ), + ); + } catch (err) { + console.debug(err); + + this.notify.toast({ + message: msg("Sorry, couldn’t save collection summary at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "update", + }); + } + } +} diff --git a/frontend/src/features/collections/collection-thumbnail.ts b/frontend/src/features/collections/collection-thumbnail.ts index 7331a1dfdc..606dc67c16 100644 --- a/frontend/src/features/collections/collection-thumbnail.ts +++ b/frontend/src/features/collections/collection-thumbnail.ts @@ -17,6 +17,9 @@ export enum Thumbnail { export const DEFAULT_THUMBNAIL = Thumbnail.Cyan; +/** + * @cssPart base + */ @localized() @customElement("btrix-collection-thumbnail") export class CollectionThumbnail extends BtrixElement { @@ -58,6 +61,7 @@ export class CollectionThumbnail extends BtrixElement { ? msg(str`Thumbnail image for “${this.collectionName}” collection`) : msg("Thumbnail image"))} src=${this.src || DEFAULT_THUMBNAIL_VARIANT.path} + part="base" /> `; } diff --git a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts index 07bfdbe97f..951c5f9e27 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts @@ -4,10 +4,14 @@ import { type CollectionEdit } from "../../collection-edit-dialog"; import gatherState from "./gather-state"; -import type { Collection, CollectionUpdate } from "@/types/collection"; +import type { + Collection, + CollectionUpdate, + PublicCollection, +} from "@/types/collection"; const checkEqual = ( - collection: Collection, + collection: Collection | PublicCollection, key: K, b: CollectionUpdate[K] | null, ) => { @@ -29,6 +33,11 @@ type KVPairs = { export default async function checkChanged(this: CollectionEdit) { try { const { collectionUpdate } = await gatherState.bind(this)(); + const collection = this.collection; + + if (!collection) { + throw new Error("no this.collection"); + } const state: CollectionUpdate = { ...collectionUpdate, @@ -38,7 +47,7 @@ export default async function checkChanged(this: CollectionEdit) { // filter out unchanged properties const updates = pairs.filter( - ([name, value]) => !checkEqual(this.collection!, name, value), + ([name, value]) => !checkEqual(collection, name, value), ) as KVPairs; if (updates.length > 0) { diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts index 2903a3818c..0b5fc2671b 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -33,6 +33,7 @@ export default function submitTask( }>("btrix-collection-saved", { detail: { id: this.collection.id, + ...updateObject, }, bubbles: true, composed: true, diff --git a/frontend/src/features/collections/edit-dialog/sharing-section.ts b/frontend/src/features/collections/edit-dialog/sharing-section.ts index 6bb7d5a28d..65e86eec8d 100644 --- a/frontend/src/features/collections/edit-dialog/sharing-section.ts +++ b/frontend/src/features/collections/edit-dialog/sharing-section.ts @@ -15,13 +15,17 @@ import { type SelectCollectionAccess } from "../select-collection-access"; import { BtrixElement } from "@/classes/BtrixElement"; import { viewStateContext, type ViewStateContext } from "@/context/view-state"; -import { CollectionAccess, type Collection } from "@/types/collection"; +import { + CollectionAccess, + type Collection, + type PublicCollection, +} from "@/types/collection"; @customElement("btrix-collection-share-settings") @localized() export class CollectionShareSettings extends BtrixElement { @property({ type: Object }) - collection?: Collection; + collection?: Collection | PublicCollection; @consume({ context: viewStateContext }) viewState?: ViewStateContext; diff --git a/frontend/src/features/collections/select-collection-thumbnail.ts b/frontend/src/features/collections/select-collection-thumbnail.ts index fd4b19ef8e..0aeb787920 100644 --- a/frontend/src/features/collections/select-collection-thumbnail.ts +++ b/frontend/src/features/collections/select-collection-thumbnail.ts @@ -76,6 +76,9 @@ export class SelectCollectionThumbnail extends BtrixElement { @property({ type: Number }) pageCount?: number; + @property({ type: Boolean }) + canEdit = false; + @state() private open = false; @@ -226,6 +229,9 @@ export class SelectCollectionThumbnail extends BtrixElement { try { if (typeof option === "string") { + this.nextThumbnailUrl = Object.entries( + CollectionThumbnail.Variants, + ).find(([name]) => (name as Thumbnail) === option)?.[1].path; await this.updateThumbnail({ defaultThumbnailName: option }, signal); } else { const screenshot = this.#screenshots.get(option.pageId); @@ -260,7 +266,12 @@ export class SelectCollectionThumbnail extends BtrixElement { id: "collection-thumbnail-update-status", }); - this.dispatchEvent(new CustomEvent("btrix-collection-saved")); + this.dispatchEvent( + new CustomEvent("btrix-collection-saved", { + bubbles: true, + composed: true, + }), + ); } catch (err) { console.debug(err); @@ -291,7 +302,7 @@ export class SelectCollectionThumbnail extends BtrixElement { } render() { - const isCrawler = this.appState.isCrawler; + const canEdit = this.canEdit; const updating = this.updateThumbnailTask.status === TaskStatus.PENDING; return html` (this.open = true)} @sl-after-show=${async () => { @@ -324,20 +335,19 @@ export class SelectCollectionThumbnail extends BtrixElement { } }} > -
+
${when( - isCrawler, + canEdit, () => html` @@ -78,12 +90,12 @@ export class ShareCollection extends BtrixElement { if ( this.context === "public" && - this.collection && - this.collection.access === CollectionAccess.Public + this.access && + this.access === CollectionAccess.Public ) { track(AnalyticsTrackEvent.CopyShareCollectionLink, { org_slug: this.orgSlug, - collection_slug: this.collection.slug, + collection_slug: this.slug, logged_in: !!this.authState, }); } @@ -99,23 +111,23 @@ export class ShareCollection extends BtrixElement { > - ${when(this.orgSlug && this.collection, (collection) => - this.context === "public" && collection.allowPublicDownload + ${when(this.orgSlug && this.allowPublicDownload !== undefined, () => + this.context === "public" && this.allowPublicDownload ? html` { track(AnalyticsTrackEvent.DownloadPublicCollection, { org_slug: this.orgSlug, - collection_slug: this.collection?.slug, + collection_slug: this.slug, }); }} > @@ -131,7 +143,7 @@ export class ShareCollection extends BtrixElement { private renderDialog() { return html` { this.showDialog = false; @@ -184,7 +196,7 @@ export class ShareCollection extends BtrixElement { return html` ${when( - this.collection?.access === CollectionAccess.Private, + this.access === CollectionAccess.Private, () => html` ${msg("Change the visibility setting to embed this collection.")} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 63e7bea19f..158d271e43 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -879,7 +879,7 @@ export class App extends BtrixElement { } return html`[0] & { breadcrumbs?: Parameters[0]; + content?: TemplateResult; + aside?: TemplateResult; }, render: () => TemplateResult, ) { @@ -39,10 +42,20 @@ export function page( >
- ${header.breadcrumbs ? html` ${pageNav(header.breadcrumbs)} ` : nothing} - ${pageHeader(header)} + ${header.breadcrumbs || header.aside + ? html`
+ ${header.breadcrumbs ? pageNav(header.breadcrumbs) : nothing} + ${header.aside} +
` + : nothing} + ${header.content || pageHeader(header)} ${header.title ? html`
${render()}
` : render()} diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index a216285542..bdc10166e0 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -72,13 +72,15 @@ function pageBreadcrumbs(breadcrumbs: Breadcrumb[]) { export function pageBack({ href, content }: Breadcrumb) { if (!href) return; - return breadcrumbLink({ - href, - content: html` - - ${content ? html` ${msg("Back to")} ${content}` : msg("Back")} - `, - }); + return html`
+ ${breadcrumbLink({ + href, + content: html` + + ${content ? html` ${msg("Back to")} ${content}` : msg("Back")} + `, + })} +
`; } export function pageTitle( diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts index 166236dad9..0803d76168 100644 --- a/frontend/src/pages/collections/collection.ts +++ b/frontend/src/pages/collections/collection.ts @@ -1,19 +1,25 @@ +import { provide } from "@lit/context"; import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; -import { html, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { html, nothing, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; +import type { ReplayWebPage, RwpUrlChangeEvent } from "replaywebpage"; import { BtrixElement } from "@/classes/BtrixElement"; +import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import { metadataColumn } from "@/layouts/collections/metadataColumn"; import { page } from "@/layouts/page"; -import { RouteNamespace } from "@/routes"; -import type { PublicCollection } from "@/types/collection"; +import { collectionRwpContext } from "@/pages/org/collection-detail/context/collection-rwp"; +import { type CollectionSavedEvent } from "@/pages/org/collection-detail/types"; +import { CommonTab, OrgTab, RouteNamespace } from "@/routes"; +import { CollectionAccess, type PublicCollection } from "@/types/collection"; import { formatRwpTimestamp } from "@/utils/replay"; -import { richText } from "@/utils/rich-text"; -enum Tab { +import "@/features/collections/collection-page-header"; + +enum PublicTab { Replay = "replay", About = "about", } @@ -21,6 +27,9 @@ enum Tab { @localized() @customElement("btrix-collection") export class Collection extends BtrixElement { + @provide({ context: collectionRwpContext }) + replayEmbed?: ReplayWebPage | null; + @property({ type: String }) orgSlug?: string; @@ -28,21 +37,28 @@ export class Collection extends BtrixElement { collectionSlug?: string; @property({ type: String }) - tab: Tab | string = Tab.Replay; + tab: PublicTab | string = PublicTab.Replay; + + @state() + private showEditDialog = false; get canEditCollection() { return this.orgSlug === this.orgSlugState && this.appState.isCrawler; } + get privatePageUrl() { + return `${this.navigate.orgBasePath}/${OrgTab.Collections}/${CommonTab.View}/${this.collection.value?.id ?? ""}`; + } + private readonly tabLabels: Record< - Tab, + PublicTab, { icon: { name: string; library: string }; text: string } > = { - [Tab.Replay]: { + [PublicTab.Replay]: { icon: { name: "replaywebpage", library: "app" }, text: msg("Browse Collection"), }, - [Tab.About]: { + [PublicTab.About]: { icon: { name: "info-square-fill", library: "default" }, text: msg("About This Collection"), }, @@ -63,8 +79,11 @@ export class Collection extends BtrixElement { ); } - if (!collection.crawlCount && (this.tab as unknown) === Tab.Replay) { - this.tab = Tab.About; + if ( + !collection.crawlCount && + (this.tab as unknown) === PublicTab.Replay + ) { + this.tab = PublicTab.About; } return collection; @@ -73,10 +92,72 @@ export class Collection extends BtrixElement { }); render() { - return this.collection.render({ - complete: this.renderComplete, - error: this.renderError, - }); + return html` + ${this.collection.render({ + complete: this.renderComplete, + pending: () => + this.collection.value + ? this.renderComplete(this.collection.value) + : // TODO Add skeleton layout + nothing, + error: this.renderError, + })} + ${when( + this.collection.value, + (collection) => + html` (this.showEditDialog = false)} + @btrix-collection-saved=${async (e: CollectionSavedEvent) => { + if (e.detail?.access === CollectionAccess.Private) { + // Redirect to private page + this.navigate.to(this.privatePageUrl); + } else { + void this.collection.run(); + } + }} + >`, + )} + `; + } + + private renderActions() { + const collection = this.collection.value; + + if (!collection) return; + + return html`
+ +
+
+ ${SelectCollectionAccess.Options[collection.access].label} +
+

${SelectCollectionAccess.Options[collection.access].detail}

+
+ { + this.showEditDialog = true; + }} + > + + ${msg("Share")} + +
+ + + ${msg("Manage")} + +
`; } private readonly renderComplete = (collection: PublicCollection) => { @@ -90,44 +171,34 @@ export class Collection extends BtrixElement { }, ] : undefined, - title: collection.name || "", - actions: html` - - ${when( - this.canEditCollection, - () => html` - - ${msg("Go to Private Page")} - - `, - )} - `, + collectionName=${collection.name} + slug=${collection.slug} + caption=${collection.caption ?? ""} + access=${collection.access} + collectionSize=${collection.totalSize} + homeUrl=${collection.homeUrl || ""} + homeUrlTs=${collection.homeUrlTs || ""} + thumbnailName=${collection.defaultThumbnailName || ""} + thumbnailPath=${collection.thumbnail?.path || ""} + pageCount=${collection.pageCount} + ?allowPublicDownload=${collection.allowPublicDownload} + @btrix-collection-saved=${(e: CollectionSavedEvent) => { + e.stopPropagation(); + void this.collection.run(); + }} + > +
`, + aside: this.canEditCollection ? this.renderActions() : undefined, }; - if (collection.caption) { - header.secondary = html` -
- ${richText(collection.caption)} -
- `; - } - - const panel = (tab: Tab, content: TemplateResult) => html` + const panel = (tab: PublicTab, content: TemplateResult) => html`
@@ -140,14 +211,16 @@ export class Collection extends BtrixElement { header, () => html` ${when(collection.crawlCount, () => - panel(Tab.Replay, this.renderReplay(collection)), + panel(PublicTab.Replay, this.renderReplay(collection)), )} - ${panel(Tab.About, this.renderAbout(collection))} + ${panel(PublicTab.About, this.renderAbout(collection))} `, )} `; @@ -161,8 +234,8 @@ export class Collection extends BtrixElement {
`; }; - private readonly renderTab = (tab: Tab) => { - const isSelected = tab === (this.tab as Tab); + private readonly renderTab = (tab: PublicTab) => { + const isSelected = tab === (this.tab as PublicTab); return html` { + if (!this.replayEmbed) { + this.replayEmbed = e.currentTarget as ReplayWebPage; + } + }} > `; diff --git a/frontend/src/pages/org/collection-detail/collection-detail.ts b/frontend/src/pages/org/collection-detail/collection-detail.ts index 880e556196..0c82365a92 100644 --- a/frontend/src/pages/org/collection-detail/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail/collection-detail.ts @@ -21,27 +21,23 @@ import { CollectionSearchParam, EditingSearchParamValue, Tab, + type CollectionSavedEvent, type Dialog, type OpenDialogEventDetail, } from "./types"; import { getThumbnailBlob } from "./utils/getThumbnailBlob"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { EditableTextBoxChangeEvent } from "@/components/ui/editable-text-box"; -import type { - EditableTextFieldChangeEvent, - EditableTextFieldInputEvent, -} from "@/components/ui/editable-text-field"; import type { MarkdownEditor } from "@/components/ui/markdown-editor"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { viewStateContext, type ViewStateContext } from "@/context/view-state"; import { ClipboardController } from "@/controllers/clipboard"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; import type { BtrixRequestOrgUpdate } from "@/events/btrix-request-org-update"; +import type { CollectionPageHeader } from "@/features/collections/collection-page-header"; import { DEFAULT_THUMBNAIL } from "@/features/collections/collection-thumbnail"; import { collectionShareLink } from "@/features/collections/helpers/share-link"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; -import type { SelectCollectionThumbnail } from "@/features/collections/select-collection-thumbnail"; import { createIndexDialog } from "@/features/collections/templates/create-index-dialog"; import { deleteIndexDialog } from "@/features/collections/templates/delete-index-dialog"; import { purgeIndexDialog } from "@/features/collections/templates/purge-index-dialog"; @@ -50,10 +46,9 @@ import { metadataItemWithCollection, } from "@/layouts/collections/metadataColumn"; import { emptyMessage } from "@/layouts/emptyMessage"; -import { pageNav, pageTitle, type Breadcrumb } from "@/layouts/pageHeader"; +import { pageNav, type Breadcrumb } from "@/layouts/pageHeader"; import { panelBody, panelHeader } from "@/layouts/panel"; import { updatingOverlay } from "@/layouts/updatingOverlay"; -import { OrgTab, RouteNamespace } from "@/routes"; import { getIndexErrorMessage } from "@/strings/collections/index-error"; import { type APIPaginatedList, @@ -61,9 +56,6 @@ import { type APISortQuery, } from "@/types/api"; import { - COLLECTION_CAPTION_MAX_LENGTH, - COLLECTION_NAME_MAX_LENGTH, - CollectionAccess, collectionSchema, type Collection, type PublicCollection, @@ -79,10 +71,9 @@ import { indexAvailable, indexInUse, indexUpdating } from "@/utils/dedupe"; import { isNotEqual } from "@/utils/is-not-equal"; import { pluralOf } from "@/utils/pluralize"; import { formatRwpTimestamp } from "@/utils/replay"; -import { richText } from "@/utils/rich-text"; -import slugifyStrict from "@/utils/slugify"; import { tw } from "@/utils/tailwind"; -import { toShortUrl } from "@/utils/url-helpers"; + +import "@/features/collections/collection-page-header"; const ABORT_REASON_THROTTLE = "throttled"; const INITIAL_ITEMS_PAGE_SIZE = 20; @@ -119,9 +110,6 @@ export class CollectionDetail extends BtrixElement { @state() private rwpDoFullReload = false; - @state() - private slugPreview = ""; - @state() private replayCurrentView?: { url: string; ts?: string }; @@ -131,12 +119,12 @@ export class CollectionDetail extends BtrixElement { @provide({ context: collectionRwpContext }) replayEmbed?: ReplayWebPage | null; + @query("btrix-collection-page-header") + private readonly pageHeader?: CollectionPageHeader | null; + @query("btrix-markdown-editor") private readonly descriptionEditor?: MarkdownEditor | null; - @query("btrix-select-collection-thumbnail") - private readonly selectCollectionThumbnail?: SelectCollectionThumbnail | null; - // Use to cancel requests private getArchivedItemsController: AbortController | null = null; @@ -440,95 +428,44 @@ export class CollectionDetail extends BtrixElement { const collection_name = html`${this.collection?.name}`; - const showCaption = this.isCrawler || this.collection?.caption; return html`
${this.renderBreadcrumbs()}
-
-
-
- ${when( - this.collection, - this.renderThumbnail, - () => - html``, - )} -
-
-
-
- ${pageTitle( - when(this.collection, this.renderName), - tw`mb-2 h-6 w-60`, - tw`grid`, - )} -
-
${this.renderAccessDetails()}
-
- ${showCaption - ? html`
- ${this.isCrawler - ? when( - this.collection, - (col) => - html` { - void this.updateSummary(e.detail.value); - }} - >`, - ) - : this.collection?.caption - ? this.renderCaption(this.collection.caption) - : nothing} -
` - : nothing} + thumbnailPath=${ifDefined(this.collection?.thumbnail?.path)} + .thumbnailSource=${this.collection?.thumbnailSource} + pageCount=${ifDefined(this.collection?.pageCount)} + ?allowPublicDownload=${this.collection?.allowPublicDownload} + @btrix-collection-saved=${(e: CollectionSavedEvent) => { + e.stopPropagation(); + + if (this.collection && e.detail) { + this.collection = { + ...this.collection, + ...e.detail, + }; + } -
- { - e.stopPropagation(); - void this.fetchCollection(); - }} - > - ${when(this.isCrawler, this.renderActions)} -
-
+ void this.fetchCollection(); + }} + > +
${this.renderActions()}
+
@@ -834,194 +770,6 @@ export class CollectionDetail extends BtrixElement { `; } - private readonly renderThumbnail = (collection: Collection) => { - return html` - { - void this.fetchCollection(); - }} - > - `; - }; - - private readonly renderName = (collection: Collection) => { - if (!this.isCrawler) { - return html`
${collection.name}
`; - } - - return html` { - e.stopPropagation(); - - const { value } = e.detail; - - this.slugPreview = value ? slugifyStrict(value) : ""; - }} - @btrix-change=${(e: EditableTextFieldChangeEvent) => { - e.stopPropagation(); - - const value = e.detail.value.trim(); - - if (value === this.collection?.name) { - this.slugPreview = ""; - } - - void this.updateName(value); - }} - extraWidth=${24} - > - - - - `; - }; - - private readonly renderAccessDetails = () => { - if (!this.collection) { - return html``; - } - - const badge = html` - - ${SelectCollectionAccess.Options[this.collection.access].label} - `; - - const publicLink = () => { - const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; - const namespacedPath = `${RouteNamespace.PublicOrgs}/${this.viewState?.params.slug}/${OrgTab.Collections}`; - const slugPreview = this.slugPreview || this.collection?.slug || ""; - const link = new URL(`${baseUrl}/${namespacedPath}/${slugPreview}`).href; - const displayUrl = html` - ${toShortUrl(baseUrl, null)}/.../${slugPreview} - `; - - return html` ${this.slugPreview - ? displayUrl - : html` - ${displayUrl} - - `}`; - }; - - return html`
- ${badge} - ${when(this.collection.access !== CollectionAccess.Private, publicLink)} -
`; - }; - - private readonly renderCaption = (text: string) => { - return html`${richText(text, { - linkClass: tw`text-cyan-500 transition-colors hover:text-cyan-600`, - })}`; - }; - - private async refreshReplay() { - if (this.replayEmbed) { - try { - await this.replayEmbed.fullReload(); - } catch (e) { - console.warn("Full reload not available in RWP"); - } - } else { - this.rwpDoFullReload = true; - } - } - - private readonly renderBreadcrumbs = () => { - const breadcrumbs: Breadcrumb[] = [ - { - href: `${this.navigate.orgBasePath}/collections`, - content: msg("Collections"), - }, - { - content: this.collection?.name, - }, - ]; - - return pageNav(breadcrumbs); - }; - - private readonly renderTabs = () => { - let tabs = Object.values(Tab); - - if (this.featureFlags.excludes("dedupeEnabled")) { - tabs = tabs.filter((tab) => tab !== Tab.Deduplication); - } - - return html` - - - - `; - }; - private readonly renderActions = () => { const authToken = this.authState?.headers.Authorization.split(" ")[1]; const showShare = this.collection?.crawlCount; @@ -1167,6 +915,72 @@ export class CollectionDetail extends BtrixElement { `; }; + private async refreshReplay() { + if (this.replayEmbed) { + try { + await this.replayEmbed.fullReload(); + } catch (e) { + console.warn("Full reload not available in RWP"); + } + } else { + this.rwpDoFullReload = true; + } + } + + private readonly renderBreadcrumbs = () => { + const breadcrumbs: Breadcrumb[] = [ + { + href: `${this.navigate.orgBasePath}/collections`, + content: msg("Collections"), + }, + { + content: this.collection?.name, + }, + ]; + + return pageNav(breadcrumbs); + }; + + private readonly renderTabs = () => { + let tabs = Object.values(Tab); + + if (this.featureFlags.excludes("dedupeEnabled")) { + tabs = tabs.filter((tab) => tab !== Tab.Deduplication); + } + + return html` + + + + `; + }; + private renderDedupeMenuItems() { if (!this.collection) return; @@ -1817,93 +1631,6 @@ export class CollectionDetail extends BtrixElement { } } - private async updateName(name: string) { - if (name === this.collection?.name) { - return; - } - - try { - await this.api.fetch( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ - name, - slug: slugifyStrict(name), - }), - }, - ); - - this.notify.toast({ - message: msg("Name updated."), - variant: "success", - icon: "check2-circle", - id: "update", - }); - - if (this.collection) { - this.collection = { - ...this.collection, - name, - slug: this.slugPreview || this.collection.slug || "", - }; - } - - await this.fetchCollection(); - - this.slugPreview = ""; - } catch (err) { - console.debug(err); - - this.notify.toast({ - message: msg("Sorry, couldn’t save collection name at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } - - private async updateSummary(caption: string) { - caption = caption.trim(); - if (caption === this.collection?.caption) return; - try { - await this.api.fetch( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ - caption, - }), - }, - ); - - this.notify.toast({ - message: msg("Summary updated."), - variant: "success", - icon: "check2-circle", - id: "update", - }); - - if (this.collection) { - this.collection = { - ...this.collection, - caption, - }; - } - - void this.fetchCollection(); - } catch (err) { - console.debug(err); - - this.notify.toast({ - message: msg("Sorry, couldn’t save collection summary at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "update", - }); - } - } - private async saveDescription() { if (!this.descriptionEditor?.checkValidity()) { // TODO diff --git a/frontend/src/pages/org/collection-detail/types.ts b/frontend/src/pages/org/collection-detail/types.ts index 8da1635672..4c87e1bcf1 100644 --- a/frontend/src/pages/org/collection-detail/types.ts +++ b/frontend/src/pages/org/collection-detail/types.ts @@ -1,3 +1,5 @@ +import type { Collection } from "@/types/collection"; + export enum Tab { Replay = "replay", About = "about", @@ -22,3 +24,7 @@ export enum CollectionSearchParam { export enum EditingSearchParamValue { Items = "items", } + +export type CollectionSavedEvent = CustomEvent< + { id: string } & Partial +>; diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index f511b04383..443f80b2d1 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -20,10 +20,10 @@ import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { WithSearchOrgContext } from "@/context/search-org/WithSearchOrgContext"; import { ClipboardController } from "@/controllers/clipboard"; import type { BtrixRequestOrgUpdate } from "@/events/btrix-request-org-update"; -import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import { emptyMessage } from "@/layouts/emptyMessage"; import { pageHeader } from "@/layouts/pageHeader"; +import type { CollectionSavedEvent } from "@/pages/org/collection-detail/types"; import { RouteNamespace } from "@/routes"; import { getIndexErrorMessage } from "@/strings/collections/index-error"; import { metadata } from "@/strings/collections/metadata"; diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index aa25847064..16b941b1ac 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -16,10 +16,10 @@ import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; -import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; import { storageColorClasses } from "@/features/meters/storage/colors"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; +import type { CollectionSavedEvent } from "@/pages/org/collection-detail/types"; import { RouteNamespace } from "@/routes"; import type { APIPaginatedList, APISortQuery } from "@/types/api"; import { CollectionAccess, type Collection } from "@/types/collection"; diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 3ae54c34f9..4f251aa8b5 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -9,7 +9,10 @@ import { when } from "lit/directives/when.js"; import isEqual from "lodash/fp/isEqual"; import type { QATab } from "./archived-item-qa/types"; -import type { Tab as CollectionTab } from "./collection-detail/types"; +import type { + CollectionSavedEvent, + Tab as CollectionTab, +} from "./collection-detail/types"; import type { Member, OrgRemoveMemberEvent, @@ -35,7 +38,6 @@ import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; import type { BtrixRequestOrgUpdate } from "@/events/btrix-request-org-update"; -import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { CommonTab, OrgTab, RouteNamespace, WorkflowTab } from "@/routes"; import type { diff --git a/frontend/src/types/collection.ts b/frontend/src/types/collection.ts index e170c236aa..692b83aea6 100644 --- a/frontend/src/types/collection.ts +++ b/frontend/src/types/collection.ts @@ -103,9 +103,7 @@ export const collectionUpdateSchema = z description: z.string(), caption: z.string(), access: z.string(), - defaultThumbnailName: z.string().nullable(), allowPublicDownload: z.boolean(), - thumbnailSource: collectionThumbnailSourceSchema.nullable(), }) .partial();