diff --git a/backend/btrixcloud/colls.py b/backend/btrixcloud/colls.py index cfef43d1d5..468207adbf 100644 --- a/backend/btrixcloud/colls.py +++ b/backend/btrixcloud/colls.py @@ -3,7 +3,7 @@ """ # pylint: disable=too-many-lines - +from datetime import datetime from collections import Counter from uuid import UUID, uuid4 from typing import Optional, List, TYPE_CHECKING, cast, Dict, Tuple, Any, Union @@ -20,10 +20,12 @@ from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .models import ( + AnyHttpUrl, Collection, CollIn, CollOut, CollIdName, + CollectionThumbnailSource, UpdateColl, AddRemoveCrawlList, BaseCrawl, @@ -843,8 +845,17 @@ async def set_home_url( return {"updated": True} + # pylint: disable=too-many-locals async def upload_thumbnail_stream( - self, stream, filename: str, coll_id: UUID, org: Organization, user: User + self, + stream, + filename: str, + coll_id: UUID, + org: Organization, + user: User, + source_url: Optional[AnyHttpUrl] = None, + source_ts: Optional[datetime] = None, + source_page_id: Optional[UUID] = None, ) -> Dict[str, bool]: """Upload file as stream to use as collection thumbnail""" coll = await self.get_collection(coll_id) @@ -903,6 +914,13 @@ async def stream_iter(): coll.thumbnail = thumbnail_file + if source_url and source_ts and source_page_id: + coll.thumbnailSource = CollectionThumbnailSource( + url=source_url, + urlTs=source_ts, + urlPageId=source_page_id, + ) + # Update entire document to avoid bson.errors.InvalidDocument exception await self.collections.find_one_and_update( {"_id": coll_id, "oid": org.id}, @@ -1226,11 +1244,21 @@ async def upload_thumbnail_stream( request: Request, filename: str, coll_id: UUID, + sourceUrl: Optional[AnyHttpUrl], + sourceTs: Optional[datetime], + sourcePageId: Optional[UUID], org: Organization = Depends(org_crawl_dep), user: User = Depends(user_dep), ): return await colls.upload_thumbnail_stream( - request.stream(), filename, coll_id, org, user + request.stream(), + filename, + coll_id, + org, + user, + sourceUrl, + sourceTs, + sourcePageId, ) @app.delete( diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 38734d7915..33f13415f9 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1236,6 +1236,15 @@ class CollAccessType(str, Enum): PUBLIC = "public" +# ============================================================================ +class CollectionThumbnailSource(BaseModel): + """The page source for a thumbnail""" + + url: AnyHttpUrl + urlTs: datetime + urlPageId: UUID + + # ============================================================================ class Collection(BaseMongoModel): """Org collection structure""" @@ -1268,6 +1277,7 @@ class Collection(BaseMongoModel): homeUrlPageId: Optional[UUID] = None thumbnail: Optional[ImageFile] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: Optional[bool] = True @@ -1323,6 +1333,7 @@ class CollOut(BaseMongoModel): resources: List[CrawlFileOut] = [] thumbnail: Optional[ImageFileOut] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: bool = True @@ -1372,6 +1383,7 @@ class UpdateColl(BaseModel): access: Optional[CollAccessType] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: Optional[bool] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None # ============================================================================ diff --git a/backend/test/test_collections.py b/backend/test/test_collections.py index e219134b95..16342c395c 100644 --- a/backend/test/test_collections.py +++ b/backend/test/test_collections.py @@ -1030,9 +1030,10 @@ def test_collection_url_list(crawler_auth_headers, default_org_id): def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): + # https://dev.browsertrix.com/api/orgs/c69247f4-415e-4abc-b449-e85d2f26c626/collections/b764fbe1-baab-4dc5-8dca-2db6f82c250b/thumbnail?filename=page-thumbnail_47fe599e-ed62-4edd-b078-93d4bf281e0f.jpeg&sourceUrl=https%3A%2F%2Fspecs.webrecorder.net%2F&sourceTs=2024-08-16T08%3A00%3A21.601000Z&sourcePageId=47fe599e-ed62-4edd-b078-93d4bf281e0f with open(os.path.join(curr_dir, "data", "thumbnail.jpg"), "rb") as fh: r = requests.put( - f"{API_PREFIX}/orgs/{default_org_id}/collections/{_public_coll_id}/thumbnail?filename=thumbnail.jpg", + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_public_coll_id}/thumbnail?filename=thumbnail.jpg&sourceUrl=https%3A%2F%2Fexample.com%2F&sourceTs=2024-08-16T08%3A00%3A21.601000Z&sourcePageId=1bba4aba-d5be-4943-ad48-d6710633d754", headers=crawler_auth_headers, data=read_in_chunks(fh), ) @@ -1044,7 +1045,8 @@ def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): headers=crawler_auth_headers, ) assert r.status_code == 200 - thumbnail = r.json()["thumbnail"] + collection = r.json() + thumbnail = collection["thumbnail"] assert thumbnail["name"] assert thumbnail["path"] @@ -1057,6 +1059,16 @@ def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): assert thumbnail["userName"] assert thumbnail["created"] + thumbnailSource = collection["thumbnailSource"] + + assert thumbnailSource["url"] + assert thumbnailSource["urlTs"] + assert thumbnailSource["urlPageId"] + + assert thumbnailSource["url"] == "https://example.com/" + assert thumbnailSource["urlTs"] == "2024-08-16T08:00:21.601000Z" + assert thumbnailSource["urlPageId"] == "1bba4aba-d5be-4943-ad48-d6710633d754" + def test_set_collection_default_thumbnail(crawler_auth_headers, default_org_id): default_thumbnail_name = "orange-default.avif" diff --git a/frontend/docs/docs/user-guide/org-settings.md b/frontend/docs/docs/user-guide/org-settings.md index a2a6a46b86..bd4a246aa5 100644 --- a/frontend/docs/docs/user-guide/org-settings.md +++ b/frontend/docs/docs/user-guide/org-settings.md @@ -1,4 +1,4 @@ -# Change Org Settings +# Edit Org Settings Settings that apply to the entire organization are found in the **Settings** page. If you're an org admin, you'll see the link to _Settings_ in the org navigation bar. @@ -13,7 +13,7 @@ The org URL is where you and other org members will go to view the dashboard, co Org name and URLs are unique to each Browsertrix instance (for example, on `app.browsertrix.com`) and you may be prompted to change the org name or URL if either are already in use by another org. ??? info "What information will be visible to the public?" - All org information is private until you make the org visible. Once your org is made visible to the public, the org name, description, and website will appear on the org's public gallery page. You can preview how information appears to the public by clicking **View as public**. + All org information is private until you make the org visible. Once your org is made visible to the public, the org name, description, and website will appear on the org's public collections gallery page. You can preview how information appears to the public by visiting the linked public collections gallery page. ### Public Collections Gallery diff --git a/frontend/src/components/ui/button.ts b/frontend/src/components/ui/button.ts index f06bd3d4e5..a03b1aa6d2 100644 --- a/frontend/src/components/ui/button.ts +++ b/frontend/src/components/ui/button.ts @@ -74,7 +74,8 @@ export class Button extends TailwindElement { small: tw`min-h-6 min-w-6 rounded-md text-base`, medium: tw`min-h-8 min-w-8 rounded-sm text-lg`, }[this.size], - this.raised && tw`shadow ring-1 ring-neutral-200`, + this.raised && + tw`shadow ring-1 ring-stone-500/20 hover:shadow-stone-800/20 hover:ring-stone-800/20`, this.filled ? [ tw`text-white`, diff --git a/frontend/src/components/ui/navigation/navigation-button.ts b/frontend/src/components/ui/navigation/navigation-button.ts index c9de311512..b3da1a9b30 100644 --- a/frontend/src/components/ui/navigation/navigation-button.ts +++ b/frontend/src/components/ui/navigation/navigation-button.ts @@ -1,5 +1,6 @@ /* eslint-disable lit/binding-positions */ /* eslint-disable lit/no-invalid-html */ +import clsx from "clsx"; import { css, type PropertyValueMap } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -26,6 +27,9 @@ export class NavigationButton extends TailwindElement { @property({ type: String }) type: "submit" | "button" = "button"; + @property({ type: String }) + variant: "primary" | "error" = "primary"; // TODO expand if necessary + @property({ type: String }) label?: string; @@ -76,8 +80,9 @@ export class NavigationButton extends TailwindElement { return html`<${tag} type=${this.type === "submit" ? "submit" : "button"} part="button" - class=${[ - tw`flex w-full cursor-pointer items-center gap-2 rounded font-medium leading-[16px] outline-primary-600 transition hover:transition-none focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-50`, + class=${clsx([ + tw`flex w-full cursor-pointer items-center gap-2 rounded font-medium leading-[16px] transition hover:transition-none focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-50`, + this.icon ? tw`min-h-6 min-w-6` : tw``, { small: this.icon ? tw`min-h-6 p-0` : tw`min-h-6 px-2 py-0`, @@ -89,17 +94,27 @@ export class NavigationButton extends TailwindElement { center: "justify-center", right: "justify-end", }[this.align], - this.active - ? tw`bg-primary-100/80 text-primary-800 shadow-sm shadow-primary-900/20` - : tw`text-neutral-700 hover:bg-primary-50`, - ] - .filter(Boolean) - .join(" ")} + this.active && "shadow-sm", + { + primary: [ + tw`outline-primary-600`, + this.active + ? tw`bg-primary-100/80 text-primary-800 shadow-primary-900/20` + : tw`text-neutral-700 hover:bg-primary-50`, + ], + error: [ + tw`outline-red-600`, + this.active + ? tw`bg-red-100/80 text-red-800 shadow-red-900/20` + : tw`text-red-700 ring-1 ring-red-300 hover:bg-red-50`, + ], + }[this.variant], + ])} ?disabled=${this.disabled} href=${ifDefined(this.href)} aria-label=${ifDefined(this.label)} @click=${this.handleClick} - + > `; diff --git a/frontend/src/components/ui/tab-group/tab-group.ts b/frontend/src/components/ui/tab-group/tab-group.ts index fe34449e9b..5668bef60c 100644 --- a/frontend/src/components/ui/tab-group/tab-group.ts +++ b/frontend/src/components/ui/tab-group/tab-group.ts @@ -12,6 +12,7 @@ import { TailwindElement } from "@/classes/TailwindElement"; import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav"; /** + * @fires btrix-tab-change * @example Usage: * ```ts * @@ -145,5 +146,11 @@ export class TabGroup extends TailwindElement { private onSelectTab(e: CustomEvent) { e.stopPropagation(); this.active = e.detail.panel; + this.dispatchEvent( + new CustomEvent("btrix-tab-change", { + detail: this.active, + bubbles: true, + }), + ); } } diff --git a/frontend/src/context/view-state.ts b/frontend/src/context/view-state.ts new file mode 100644 index 0000000000..210a0b6f0a --- /dev/null +++ b/frontend/src/context/view-state.ts @@ -0,0 +1,7 @@ +import { createContext } from "@lit/context"; + +import { type ViewState } from "@/utils/APIRouter"; + +export type ViewStateContext = ViewState | null; + +export const viewStateContext = createContext("viewState"); diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index 44c9e44030..f27f86b0f0 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -173,6 +173,7 @@ export class APIController implements ReactiveController { async upload( path: string, file: File, + abortSignal?: AbortSignal, ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { const auth = appState.auth; @@ -185,9 +186,12 @@ export class APIController implements ReactiveController { } return new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(AbortReason.UserCancel); + } const xhr = new XMLHttpRequest(); - xhr.open("PUT", `/api/${path}`); + xhr.open("PUT", `/api${path}`); xhr.setRequestHeader("Content-Type", "application/octet-stream"); Object.entries(auth.headers).forEach(([k, v]) => { xhr.setRequestHeader(k, v); @@ -221,6 +225,11 @@ export class APIController implements ReactiveController { xhr.send(file); + abortSignal?.addEventListener("abort", () => { + xhr.abort(); + reject(AbortReason.UserCancel); + }); + this.uploadRequest = xhr; }); } diff --git a/frontend/src/decorators/needLogin.test.ts b/frontend/src/decorators/needLogin.test.ts index 79a1fa940c..66047fa70a 100644 --- a/frontend/src/decorators/needLogin.test.ts +++ b/frontend/src/decorators/needLogin.test.ts @@ -19,6 +19,7 @@ describe("needLogin", () => { } const Element = needLogin( + // @ts-expect-error not stubbing full BtrixElement class TestElement extends LiteElementMock { appState = appState; } as unknown as { diff --git a/frontend/src/features/collections/collection-metadata-dialog.ts b/frontend/src/features/collections/collection-create-dialog.ts similarity index 78% rename from frontend/src/features/collections/collection-metadata-dialog.ts rename to frontend/src/features/collections/collection-create-dialog.ts index ecc6909e9f..bb400b618a 100644 --- a/frontend/src/features/collections/collection-metadata-dialog.ts +++ b/frontend/src/features/collections/collection-create-dialog.ts @@ -1,7 +1,7 @@ import { localized, msg, str } from "@lit/localize"; import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; -import { html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property, @@ -28,12 +28,9 @@ export type CollectionSavedEvent = CustomEvent<{ /** * @fires btrix-collection-saved CollectionSavedEvent Fires */ -@customElement("btrix-collection-metadata-dialog") +@customElement("btrix-collection-create-dialog") @localized() -export class CollectionMetadataDialog extends BtrixElement { - @property({ type: Object }) - collection?: Collection; - +export class CollectionCreateDialog extends BtrixElement { @property({ type: Boolean }) open = false; @@ -62,10 +59,8 @@ export class CollectionMetadataDialog extends BtrixElement { } render() { - return html` (this.isDialogVisible = true)} @sl-after-hide=${() => (this.isDialogVisible = false)} @@ -98,9 +93,7 @@ export class CollectionMetadataDialog extends BtrixElement { ); form.requestSubmit(submitInput); }} - >${this.collection - ? msg("Save") - : msg("Create Collection")}${msg("Create Collection")} `; @@ -113,7 +106,6 @@ export class CollectionMetadataDialog extends BtrixElement { class="with-max-help-text" name="name" label=${msg("Name")} - value=${this.collection?.name || ""} placeholder=${msg("My Collection")} autocomplete="off" required @@ -121,13 +113,11 @@ export class CollectionMetadataDialog extends BtrixElement { @sl-input=${this.validateNameMax.validate} > - @@ -138,12 +128,10 @@ export class CollectionMetadataDialog extends BtrixElement { ${msg( "Write a short description that summarizes this collection. If the collection is shareable, this will appear next to the collection name.", )} - ${this.collection - ? nothing - : msg( - html`You can add a longer description in the “About” - section after creating the collection.`, - )} + ${msg( + html`You can add a longer description in the “About” section + after creating the collection.`, + )} - - ${when( - !this.collection, - () => html` - - - (this.showPublicWarning = - (e.detail.item.value as CollectionAccess) === - CollectionAccess.Public)} - > - `, - )} + + + + + (this.showPublicWarning = + (e.detail.item.value as CollectionAccess) === + CollectionAccess.Public)} + > + ${when( this.showPublicWarning && this.org, (org) => html` @@ -218,18 +203,11 @@ export class CollectionMetadataDialog extends BtrixElement { const body = JSON.stringify({ name, caption, - access: - this.selectCollectionAccess?.value || - this.collection?.access || - CollectionAccess.Private, + access: this.selectCollectionAccess?.value || CollectionAccess.Private, defaultThumbnailName: DEFAULT_THUMBNAIL, }); - let path = `/orgs/${this.orgId}/collections`; - let method = "POST"; - if (this.collection) { - path = `/orgs/${this.orgId}/collections/${this.collection.id}`; - method = "PATCH"; - } + const path = `/orgs/${this.orgId}/collections`; + const method = "POST"; const data = await this.api.fetch(path, { method, body, @@ -238,14 +216,12 @@ export class CollectionMetadataDialog extends BtrixElement { this.dispatchEvent( new CustomEvent("btrix-collection-saved", { detail: { - id: this.collection?.id || data.id, + id: data.id, }, }) as CollectionSavedEvent, ); this.notify.toast({ - message: this.collection - ? msg(str`"${data.name || name}" metadata updated`) - : msg(str`Created "${data.name || name}" collection`), + message: msg(str`Created "${data.name || name}" collection`), variant: "success", icon: "check2-circle", id: "collection-metadata-status", diff --git a/frontend/src/features/collections/collection-edit-dialog.ts b/frontend/src/features/collections/collection-edit-dialog.ts new file mode 100644 index 0000000000..13350e832e --- /dev/null +++ b/frontend/src/features/collections/collection-edit-dialog.ts @@ -0,0 +1,388 @@ +import { localized, msg, str } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import { type SlRequestCloseEvent } from "@shoelace-style/shoelace"; +import { html, nothing, type PropertyValues } from "lit"; +import { + customElement, + property, + query, + queryAsync, + state, +} from "lit/decorators.js"; +import { type Embed } from "replaywebpage"; + +import { type CollectionSnapshotPreview } from "./collection-snapshot-preview"; +import { type Thumbnail } from "./collection-thumbnail"; +import checkChanged from "./edit-dialog/helpers/check-changed"; +import submitTask from "./edit-dialog/helpers/submit-task"; +import renderPresentation from "./edit-dialog/presentation-section"; +import { type CollectionShareSettings } from "./edit-dialog/sharing-section"; +import { type SelectCollectionPage } from "./select-collection-page"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import { type TabGroupPanel } from "@/components/ui/tab-group/tab-panel"; +import { + type Collection, + type CollectionThumbnailSource, +} from "@/types/collection"; +import { maxLengthValidator, type MaxLengthValidator } from "@/utils/form"; +import { formatRwpTimestamp } from "@/utils/replay"; + +type Tab = "general" | "sharing"; + +export type { Tab as EditDialogTab }; + +export type CollectionSavedEvent = CustomEvent<{ + id: string; +}>; + +export const validateNameMax = maxLengthValidator(50); +export const validateCaptionMax = maxLengthValidator(150); + +/** + * @fires btrix-collection-saved CollectionSavedEvent Fires + */ +@customElement("btrix-collection-edit-dialog") +@localized() +export class CollectionEdit extends BtrixElement { + @property({ type: Object }) + collection?: Collection; + + /** 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 + * already a collection object available where this is being used. + */ + @property({ type: String }) + collectionId?: string; + + @property({ type: Boolean }) + open = false; + + /** + * If there's an existing RWP instance loaded, pass it into this property; + * otherwise, this dialog will load its own instance. RWP is required for + * fetching thumbnails. + */ + @property({ type: Object }) + replayWebPage?: Embed | null | undefined; + + @property({ type: Boolean }) + replayLoaded = false; + + @state() + isDialogVisible = false; + + @property({ type: String }) + tab: Tab = "general"; + + @state() + errorTab: Tab | null = null; + + @state() + thumbnailIsValid: boolean | null = null; + + @state() + dirty = false; + + // Separating this out so that we can eagerly respond to name changes in dialog title & toasts + @state() + name = this.collection?.name; + + @state() + defaultThumbnailName: `${Thumbnail}` | null = + (this.collection?.defaultThumbnailName as + | `${Thumbnail}` + | null + | undefined) || null; + + @state() + selectedSnapshot: CollectionThumbnailSource | null = + this.collection?.thumbnailSource ?? null; + + @state() + blobIsLoaded = false; + + @query("btrix-dialog") + readonly dialog?: Dialog; + + @queryAsync("#collectionEditForm") + readonly form!: Promise; + + @queryAsync("btrix-collection-share-settings") + readonly shareSettings?: Promise; + + @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("collectionId") && this.collectionId) { + void this.fetchCollection(this.collectionId); + } + if (changedProperties.has("collectionId") && !this.collectionId) { + 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); + + private readonly submitTask = new Task(this, { + task: submitTask.bind(this)(), + autoRun: false, + }); + + validate(validator: MaxLengthValidator) { + return (e: CustomEvent) => { + const valid = validator.validate(e); + if (!valid) { + const el = e.target as HTMLElement; + this.errorTab = el.closest("btrix-tab-group-panel")! + .name as Tab; + } else { + this.errorTab = null; + } + }; + } + + private async onSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + + await this.submitTask.run(); + + this.dirty = false; + void this.hideDialog(); + } + + private async hideDialog() { + void this.dialog?.hide(); + } + + 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 { + if (this.open) { + this.isDialogVisible = true; + } + } + + 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.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: "globe2", + 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")} +
+
+ ${this.renderReplay()}`; + } + + private renderReplay() { + if (this.replayWebPage) return; + if (!this.collection) return; + if (!this.collection.crawlCount) return; + + 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} + `; + } + + private async fetchCollection(id: string) { + try { + this.collection = await this.getCollection(id); + } catch (e) { + this.notify.toast({ + message: msg("Sorry, couldn't retrieve Collection at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "collection-retrieve-status", + }); + } + } + + private async getCollection(id: string) { + const data = await this.api.fetch( + `/orgs/${this.orgId}/collections/${id}/replay.json`, + ); + + return data; + } +} diff --git a/frontend/src/features/collections/collection-replay-dialog.ts b/frontend/src/features/collections/collection-replay-dialog.ts index 61ae21050d..618381032b 100644 --- a/frontend/src/features/collections/collection-replay-dialog.ts +++ b/frontend/src/features/collections/collection-replay-dialog.ts @@ -10,7 +10,7 @@ import { HomeView, type CollectionSnapshotPreview, } from "./collection-snapshot-preview"; -import type { SelectSnapshotDetail } from "./select-collection-start-page"; +import type { SelectSnapshotDetail } from "./select-collection-page"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; @@ -82,7 +82,7 @@ export class CollectionStartPageDialog extends BtrixElement { this.homeView === HomeView.URL && !this.selectedSnapshot; return html` (this.showContent = true)} @@ -198,7 +198,7 @@ export class CollectionStartPageDialog extends BtrixElement {
html`
- { this.selectedSnapshot = e.detail.item; }} - > - - + > + + ${msg("Update collection thumbnail")} - - -
`, @@ -301,7 +298,8 @@ export class CollectionStartPageDialog extends BtrixElement { homeView === HomeView.URL && useThumbnail === "on" && this.selectedSnapshot && - this.collection?.homeUrlPageId !== this.selectedSnapshot.pageId; + this.collection?.thumbnailSource?.urlPageId !== + this.selectedSnapshot.pageId; // TODO get filename from rwp? const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`; let file: File | undefined; @@ -328,12 +326,22 @@ export class CollectionStartPageDialog extends BtrixElement { if (shouldUpload) { try { - if (!file || !fileName) throw new Error("file or fileName missing"); - await this.api.upload( - `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?filename=${fileName}`, - file, - ); - await this.updateThumbnail({ defaultThumbnailName: null }); + 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."), diff --git a/frontend/src/features/collections/collection-snapshot-preview.ts b/frontend/src/features/collections/collection-snapshot-preview.ts index b335149d37..f21c74c480 100644 --- a/frontend/src/features/collections/collection-snapshot-preview.ts +++ b/frontend/src/features/collections/collection-snapshot-preview.ts @@ -1,10 +1,11 @@ import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; import clsx from "clsx"; -import { html, type PropertyValues } from "lit"; +import { html, nothing, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { isEqual } from "lodash"; -import type { SelectSnapshotDetail } from "./select-collection-start-page"; +import type { SelectSnapshotDetail } from "./select-collection-page"; import { TailwindElement } from "@/classes/TailwindElement"; import { formatRwpTimestamp } from "@/utils/replay"; @@ -15,6 +16,10 @@ export enum HomeView { URL = "url", } +export type BtrixValidateDetails = { + valid: boolean; +}; + /** * Display preview of page snapshot. * @@ -32,45 +37,90 @@ export class CollectionSnapshotPreview extends TailwindElement { @property({ type: String }) view?: HomeView; - @property({ type: Object }) - snapshot?: SelectSnapshotDetail["item"]; + @property({ type: Boolean }) + noSpinner = false; + + @property({ + type: Object, + hasChanged: (a, b) => !isEqual(a, b), + }) + 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.finally(() => this.blobTask.value); + return this.blobTask.taskComplete.then(() => this.blobTask.value); } - private readonly blobTask = new Task(this, { - task: async ([collectionId, snapshot, iframeLoaded]) => { - if ( - !collectionId || - !snapshot || - !iframeLoaded || - !this.iframe?.contentWindow - ) { - return; + 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; } - - const resp = await this.iframe.contentWindow.fetch( - `/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`, - ); - - if (resp.status === 200) { - return await resp.blob(); - } - - throw new Error(`couldn't get thumbnail`); }, - args: () => [this.collectionId, this.snapshot, this.iframeLoaded] as const, + 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); @@ -95,9 +145,15 @@ export class CollectionSnapshotPreview extends TailwindElement { changedProperties.has("collectionId") || changedProperties.has("snapshot") ) { - if (this.objectUrlTask.value) { - URL.revokeObjectURL(this.objectUrlTask.value); - } + // 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; + } + }); } } @@ -110,16 +166,21 @@ export class CollectionSnapshotPreview extends TailwindElement { return this.blobTask.render({ complete: this.renderImage, - pending: this.renderSpinner, + 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")} +

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

`; } @@ -127,13 +188,23 @@ export class CollectionSnapshotPreview extends TailwindElement { return html`
- ${this.objectUrlTask.render({ - complete: (value) => - value - ? html`` - : this.renderSpinner(), - pending: () => "pending", - })} + ${this.objectUrlTask.value ? nothing : this.renderSpinner()} +
+ ${this.prevObjUrl + ? html`` + : nothing} + ${this.objectUrlTask.value + ? html`` + : nothing} +
${this.snapshot.url}
@@ -151,6 +222,8 @@ export class CollectionSnapshotPreview extends TailwindElement { src=${this.replaySrc} @load=${() => { this.iframeLoaded = true; + this.iframeLoadComplete(); + console.debug("iframe loaded"); }} > @@ -159,14 +232,19 @@ export class CollectionSnapshotPreview extends TailwindElement { } private readonly renderError = () => html` -

- ${msg("Couldn't load preview. Try another snapshot")} +

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

`; - private readonly renderSpinner = () => html` -
- -
- `; + private readonly renderSpinner = () => { + if (this.noSpinner) return; + return html` +
+ +
+ `; + }; } diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts index 161479fd95..501759dbbb 100644 --- a/frontend/src/features/collections/collections-grid.ts +++ b/frontend/src/features/collections/collections-grid.ts @@ -1,6 +1,7 @@ import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; import { html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; @@ -25,6 +26,12 @@ export class CollectionsGrid extends BtrixElement { @property({ type: Array }) collections?: PublicCollection[]; + @state() + collectionBeingEdited: string | null = null; + + @property({ type: String }) + collectionRefreshing: string | null = null; + render() { const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; @@ -58,12 +65,15 @@ export class CollectionsGrid extends BtrixElement {
    ${this.collections.map( (collection) => html` -
  • +
  • ${when(showActions, () => this.renderActions(collection))} + ${when( + this.collectionRefreshing === collection.id, + () => + html`
    + +
    `, + )}
  • `, )}
+ ${when( + showActions, + () => + html` { + this.collectionBeingEdited = null; + }} + >`, + )} `; } private readonly renderActions = (collection: PublicCollection) => html`
- - - - - ${msg("Visit Public Collections Gallery")} - - - + + { + this.collectionBeingEdited = collection.id; + }} + > + + +
`; @@ -132,7 +163,7 @@ export class CollectionsGrid extends BtrixElement { return html` ${earliestYear} - ${latestYear !== earliestYear ? html` - ${latestYear} ` : nothing} + ${latestYear !== earliestYear ? html` – ${latestYear} ` : nothing} `; } diff --git a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts new file mode 100644 index 0000000000..a322b046e4 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts @@ -0,0 +1,78 @@ +import { isEqual } from "lodash"; + +import { type CollectionEdit } from "../../collection-edit-dialog"; + +import gatherState from "./gather-state"; + +import type { Collection, CollectionUpdate } from "@/types/collection"; + +const checkEqual = ( + collection: Collection, + key: K, + b: CollectionUpdate[K] | null, +) => { + let a = collection[key] as (typeof collection)[K] | null; + // caption is sometimes null when empty, collection update has empty string instead + if (key === "caption") { + a = a || null; + b = b || null; + } + // deeply compare (for objects) + const eq = isEqual(a, b); + return eq; +}; + +type KVPairs = { + [K in keyof T]-?: readonly [K, T[K]]; +}[keyof T][]; + +export default async function checkChanged(this: CollectionEdit) { + try { + const { collectionUpdate, thumbnail, setInitialView } = + await gatherState.bind(this)(); + + const state: CollectionUpdate = { + ...collectionUpdate, + }; + + const pairs = Object.entries(state) as KVPairs; + + // filter out unchanged properties + const updates = pairs.filter( + ([name, value]) => !checkEqual(this.collection!, name, value), + ) as KVPairs< + CollectionUpdate & { + thumbnail: typeof thumbnail; + setInitialView: typeof setInitialView; + } + >; + + 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 { + this.dirty = false; + } + + return updates; + } catch (e) { + console.error(e); + this.dirty = true; + } +} diff --git a/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts new file mode 100644 index 0000000000..971bc2a7f6 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts @@ -0,0 +1,58 @@ +import { getFormControls, serialize } from "@shoelace-style/shoelace"; + +import { + type CollectionEdit, + type EditDialogTab, +} from "../../collection-edit-dialog"; + +import type { TabGroupPanel } from "@/components/ui/tab-group/tab-panel"; +import { + collectionUpdateSchema, + type CollectionUpdate, +} from "@/types/collection"; + +export default async function gatherState(this: CollectionEdit) { + const form = await this.form; + + const elements = getFormControls(form); + const invalidElement = elements.find( + (el) => !(el as HTMLInputElement).checkValidity(), + ); + if (invalidElement) { + this.errorTab = invalidElement.closest( + "btrix-tab-group-panel", + )!.name as EditDialogTab; + (invalidElement as HTMLElement).focus(); + throw new Error("invalid_data"); + } else { + this.errorTab = null; + } + + 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 { 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/snapshots.ts b/frontend/src/features/collections/edit-dialog/helpers/snapshots.ts new file mode 100644 index 0000000000..fea89e4748 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/snapshots.ts @@ -0,0 +1,26 @@ +import { type SnapshotItem } from "../../select-collection-page"; + +import { type CollectionThumbnailSource } from "@/types/collection"; + +export function sourceToSnapshot( + source: CollectionThumbnailSource | null, +): SnapshotItem | null { + if (source == null) return null; + return { + pageId: source.urlPageId, + status: 200, + ts: source.urlTs, + url: source.url, + }; +} + +export function snapshotToSource( + source: SnapshotItem | null, +): CollectionThumbnailSource | null { + if (source == null) return null; + return { + urlPageId: source.pageId, + urlTs: source.ts, + url: source.url, + }; +} diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts new file mode 100644 index 0000000000..5504aed382 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -0,0 +1,131 @@ +import { msg, str } from "@lit/localize"; +import { type TaskFunction } from "@lit/task"; + +import { type CollectionEdit } from "../../collection-edit-dialog"; + +import { + type CollectionThumbnailSource, + type CollectionUpdate, +} from "@/types/collection"; +import { isApiError } from "@/utils/api"; + +export default function submitTask( + this: CollectionEdit, +): TaskFunction { + return async (_, { signal }) => { + if (!this.collection) throw new Error("Collection is undefined"); + 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 = []; + + // 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, + 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) { + tasks.push( + await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/collections/${this.collection.id}`, + { + method: "PATCH", + body: JSON.stringify(rest), + signal, + }, + ), + ); + } + + await Promise.all(tasks); + + this.dispatchEvent( + new CustomEvent<{ + id: string; + }>("btrix-collection-saved", { + detail: { + id: this.collection.id, + }, + bubbles: true, + composed: true, + }), + ); + this.dispatchEvent(new CustomEvent("btrix-change")); + this.notify.toast({ + message: msg( + str`Updated collection “${this.name || this.collection.name}”`, + ), + variant: "success", + icon: "check2-circle", + id: "collection-metadata-status", + }); + // void this.hideDialog(); + } catch (e) { + let message = isApiError(e) && e.message; + if (message === "collection_name_taken") { + message = msg("This name is already taken."); + } + if (message === "invalid_data") { + message = msg("Please review issues with your changes."); + } + console.error(e); + this.notify.toast({ + message: message || msg("Something unexpected went wrong"), + variant: "danger", + icon: "exclamation-octagon", + id: "collection-metadata-status", + }); + throw e; + } + }; +} diff --git a/frontend/src/features/collections/edit-dialog/presentation-section.ts b/frontend/src/features/collections/edit-dialog/presentation-section.ts new file mode 100644 index 0000000000..18da48d407 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/presentation-section.ts @@ -0,0 +1,330 @@ +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 BtrixValidateDetails } 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 new file mode 100644 index 0000000000..1de86ab733 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/sharing-section.ts @@ -0,0 +1,199 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import type { + SlChangeEvent, + SlSelectEvent, + SlSwitch, +} from "@shoelace-style/shoelace"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { collectionShareLink } from "../helpers/share-link"; +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"; + +@customElement("btrix-collection-share-settings") +@localized() +export class CollectionShareSettings extends BtrixElement { + @property({ type: Object }) + collection?: Collection; + + @consume({ context: viewStateContext }) + viewState?: ViewStateContext; + + @property({ type: String }) + public access = this.collection?.access; + @property({ type: Boolean }) + public allowPublicDownload = this.collection?.allowPublicDownload; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("collection")) { + this.access = this.collection?.access; + this.allowPublicDownload = this.collection?.allowPublicDownload; + } + } + + private get shareLink() { + return collectionShareLink( + this.collection, + this.orgSlugState, + this.viewState?.params.slug || "", + ); + } + + private get publicReplaySrc() { + if (!this.collection) return; + return new URL( + `/api/orgs/${this.collection.oid}/collections/${this.collection.id}/public/replay.json`, + window.location.href, + ).href; + } + render() { + return html` +
+ { + this.access = (e.target as SelectCollectionAccess).value; + this.dispatchEvent( + new CustomEvent("btrix-change", { + bubbles: true, + }), + ); + }} + > + ${when( + this.org && + !this.org.enablePublicProfile && + this.collection?.access === CollectionAccess.Public, + () => html` + + ${msg( + "The org profile page isn't public yet. To make the org profile and this collection visible to the public, update profile visibility in org settings.", + )} + + `, + )} +
+ ${when( + this.collection?.access != CollectionAccess.Private, + () => html`
${this.renderShareLink()}
`, + )} + +
+
+ ${msg("Downloads")} + + + +
+
+ { + this.allowPublicDownload = (e.target as SlSwitch).checked; + }} + >${msg("Show download button")} +
+
+ `; + } + private readonly renderShareLink = () => { + return html` +
+
${msg("Link to Share")}
+ + + + + + +
+ `; + }; + + private readonly renderEmbedCode = () => { + const replaySrc = this.publicReplaySrc; + const embedCode = ``; + const importCode = `importScripts("https://replayweb.page/sw.js");`; + + return html` + ${when( + this.collection?.access === CollectionAccess.Private, + () => html` + + ${msg("Change the visibility setting to embed this collection.")} + + `, + )} +

+ ${msg( + html`To embed this collection into an existing webpage, add the + following embed code:`, + )} +

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

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

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

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

+ `; + }; +} diff --git a/frontend/src/features/collections/helpers/share-link.ts b/frontend/src/features/collections/helpers/share-link.ts new file mode 100644 index 0000000000..2f6dceb8a0 --- /dev/null +++ b/frontend/src/features/collections/helpers/share-link.ts @@ -0,0 +1,20 @@ +import { RouteNamespace } from "@/routes"; +import { CollectionAccess, type Collection } from "@/types/collection"; + +export function collectionShareLink( + collection: + | (Pick & Partial>) + | undefined, + privateSlug: string | null, + publicSlug: string | null, +) { + const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + if (collection) { + return `${baseUrl}/${ + collection.access === CollectionAccess.Private + ? `${RouteNamespace.PrivateOrgs}/${privateSlug}/collections/view/${collection.id}` + : `${RouteNamespace.PublicOrgs}/${publicSlug}/collections/${collection.slug}` + }`; + } + return ""; +} diff --git a/frontend/src/features/collections/index.ts b/frontend/src/features/collections/index.ts index 3480cdd694..1163924aaa 100644 --- a/frontend/src/features/collections/index.ts +++ b/frontend/src/features/collections/index.ts @@ -1,10 +1,12 @@ import("./collections-add"); import("./collections-grid"); import("./collection-items-dialog"); -import("./collection-metadata-dialog"); +import("./collection-edit-dialog"); +import("./collection-create-dialog"); import("./collection-replay-dialog"); import("./collection-workflow-list"); import("./select-collection-access"); -import("./select-collection-start-page"); +import("./select-collection-page"); import("./share-collection"); import("./collection-thumbnail"); +import("./edit-dialog/sharing-section"); diff --git a/frontend/src/features/collections/select-collection-start-page.ts b/frontend/src/features/collections/select-collection-page.ts similarity index 80% rename from frontend/src/features/collections/select-collection-start-page.ts rename to frontend/src/features/collections/select-collection-page.ts index eb01881e5d..efbd266ba8 100644 --- a/frontend/src/features/collections/select-collection-start-page.ts +++ b/frontend/src/features/collections/select-collection-page.ts @@ -9,6 +9,7 @@ import clsx from "clsx"; import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; +import { isEqual } from "lodash"; import debounce from "lodash/fp/debounce"; import filter from "lodash/fp/filter"; import flow from "lodash/fp/flow"; @@ -34,7 +35,7 @@ type Page = { snapshots: Snapshot[]; }; -type SnapshotItem = Snapshot & { url: string }; +export type SnapshotItem = Snapshot & { url: string }; export type SelectSnapshotDetail = { item: SnapshotItem | null; @@ -52,21 +53,24 @@ const sortByTs = flow( * @fires btrix-select */ @localized() -@customElement("btrix-select-collection-start-page") -export class SelectCollectionStartPage extends BtrixElement { +@customElement("btrix-select-collection-page") +export class SelectCollectionPage extends BtrixElement { @property({ type: String }) collectionId?: string; @property({ type: Object }) collection?: Collection; + @property({ type: String }) + mode: "homepage" | "thumbnail" = "homepage"; + @state() private searchQuery = ""; @state() private selectedPage?: Page; - @state() + @property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) }) public selectedSnapshot?: Snapshot; @state() @@ -76,7 +80,22 @@ export class SelectCollectionStartPage extends BtrixElement { private readonly combobox?: Combobox | null; @query("#pageUrlInput") - private readonly input?: SlInput | null; + readonly input?: SlInput | null; + + // not actually a nodejs timeout, but since node types are install this is what typescript likes + timer?: NodeJS.Timeout; + + private get url() { + return this.mode === "homepage" + ? this.collection?.homeUrl + : this.collection?.thumbnailSource?.url; + } + + private get ts() { + return this.mode === "homepage" + ? this.collection?.homeUrlTs + : this.collection?.thumbnailSource?.urlTs; + } public get page() { return this.selectedPage; @@ -92,6 +111,11 @@ export class SelectCollectionStartPage extends BtrixElement { } } + public async resetFormState() { + if (!this.collection) return; + await this.initSelection(this.collection); + } + updated(changedProperties: PropertyValues) { if (changedProperties.has("selectedSnapshot")) { this.dispatchEvent( @@ -110,13 +134,13 @@ export class SelectCollectionStartPage extends BtrixElement { } private async initSelection(collection: Collection) { - if (!collection.homeUrl && collection.pageCount !== 1) { + if (!this.url && collection.pageCount !== 1) { return; } const pageUrls = await this.getPageUrls({ id: collection.id, - urlPrefix: collection.homeUrl || "", + urlPrefix: this.url || "", pageSize: 1, }); @@ -127,12 +151,12 @@ export class SelectCollectionStartPage extends BtrixElement { const startPage = pageUrls.items[0]; if (this.input) { - this.input.value = startPage.url; + this.input.value = this.url ?? startPage.url; } this.selectedPage = this.formatPage(startPage); - const homeTs = collection.homeUrlTs; + const homeTs = this.ts; this.selectedSnapshot = homeTs ? this.selectedPage.snapshots.find(({ ts }) => ts === homeTs) @@ -177,6 +201,7 @@ export class SelectCollectionStartPage extends BtrixElement { value=${this.selectedSnapshot?.pageId || ""} ?required=${this.selectedPage && !this.selectedSnapshot} ?disabled=${!this.selectedPage} + size=${this.mode === "thumbnail" ? "small" : "medium"} hoist @sl-change=${async (e: SlChangeEvent) => { const { value } = e.currentTarget as SlSelect; @@ -231,7 +256,16 @@ export class SelectCollectionStartPage extends BtrixElement { return html` { - this.combobox?.hide(); + // Because there are situations where the input might be blurred and + // then immediate refocused (e.g. clicking on the thumbnail preview in + // the collection settings dialog), a delay here prevents issues from + // the order of events being wrong — for some reason sometimes the + // blur event occurs after the focus event. This also prevents the + // combobox from disappearing and then appearing again, instead it + // just stays open. + this.timer = setTimeout(() => { + this.combobox?.hide(); + }, 150); }} > 1} - @sl-focus=${() => { + ?disabled=${!this.collection?.pageCount} + size=${this.mode === "thumbnail" ? "small" : "medium"} + autocomplete="off" + @sl-focus=${async () => { + if (this.timer) clearTimeout(this.timer); this.resetInputValidity(); this.combobox?.show(); }} @@ -256,16 +294,18 @@ export class SelectCollectionStartPage extends BtrixElement { @sl-blur=${this.pageUrlOnBlur} >
- - - + + + + +
${this.renderSearchResults()} diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts index 2d1ec3d55e..3ee177eb4a 100644 --- a/frontend/src/features/collections/share-collection.ts +++ b/frontend/src/features/collections/share-collection.ts @@ -1,26 +1,12 @@ import { localized, msg, str } from "@lit/localize"; -import type { - SlChangeEvent, - SlSelectEvent, - SlSwitch, - SlTabGroup, -} from "@shoelace-style/shoelace"; import { html, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; +import { customElement, property, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import { - CollectionThumbnail, - DEFAULT_THUMBNAIL_VARIANT, - Thumbnail, -} from "./collection-thumbnail"; -import { SelectCollectionAccess } from "./select-collection-access"; +import { collectionShareLink } from "./helpers/share-link"; import { BtrixElement } from "@/classes/BtrixElement"; import { ClipboardController } from "@/controllers/clipboard"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/collections/alerts"; import { AnalyticsTrackEvent } from "@/trackEvents"; import { CollectionAccess, @@ -29,11 +15,6 @@ import { } from "@/types/collection"; import { track } from "@/utils/analytics"; -enum Tab { - Link = "link", - Embed = "embed", -} - /** * @fires btrix-change */ @@ -47,26 +28,22 @@ export class ShareCollection extends BtrixElement { collectionId = ""; @property({ type: Object }) - collection?: Partial; + collection?: Collection | PublicCollection; + + @property({ type: String }) + context: "private" | "public" = "public"; @state() private showDialog = false; - @query("sl-tab-group") - private readonly tabGroup?: SlTabGroup | null; - private readonly clipboardController = new ClipboardController(this); private get shareLink() { - const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; - if (this.collection) { - return `${baseUrl}/${ - this.collection.access === CollectionAccess.Private - ? `${RouteNamespace.PrivateOrgs}/${this.orgSlugState}/collections/view/${this.collectionId}` - : `${RouteNamespace.PublicOrgs}/${this.orgSlug}/collections/${this.collection.slug}` - }`; - } - return ""; + return collectionShareLink( + this.collection, + this.orgSlugState, + this.orgSlug, + ); } private get publicReplaySrc() { @@ -85,158 +62,72 @@ export class ShareCollection extends BtrixElement { } private renderButton() { - if (!this.collection) { - return html` - - `; - } + if (!this.collection) return; - if (this.collection.access === CollectionAccess.Private) { - return html` - (this.showDialog = true)} - > - - ${msg("Share")} - - `; - } + if (this.collection.access === CollectionAccess.Private) return; return html` - - - + this.shareLink} + content=${msg("Copy 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, + }); + } + }} + > + + { - void this.clipboardController.copy(this.shareLink); - - if (this.collection?.access === CollectionAccess.Public) { - track(AnalyticsTrackEvent.CopyShareCollectionLink, { - org_slug: this.orgSlug, - collection_slug: this.collection.slug, - logged_in: !!this.authState, - }); - } + this.showDialog = true; }} > - - - ${msg("Copy Link")} - + - - - - - { - this.tabGroup?.show(Tab.Embed); - this.showDialog = true; - }} - > - - ${msg("View Embed Code")} - - ${when( - this.authState && !this.navigate.isPublicPage, - () => html` - + this.context === "public" && + collection.access === CollectionAccess.Public && + collection.allowPublicDownload + ? html` + - ${this.collection?.access === CollectionAccess.Unlisted - ? html` - - ${msg("Visit Unlisted Page")} - ` - : html` - - ${msg("Visit Public Page")} - `} - - ${this.appState.isCrawler - ? html` - - { - this.showDialog = true; - }} - > - - ${msg("Link Settings")} - - ` - : nothing} - `, - )} - ${when(this.orgSlug && this.collection, (collection) => - collection.access === CollectionAccess.Public && - collection.allowPublicDownload - ? html` - { - track(AnalyticsTrackEvent.DownloadPublicCollection, { - org_slug: this.orgSlug, - collection_slug: this.collection?.slug, - }); - }} - > - - ${msg("Download Collection")} - ${when( - this.collection, - (collection) => html` - ${this.localize.bytes( - collection.totalSize || 0, - )} - `, - )} - - ` - : nothing, - )} - - - + { + track(AnalyticsTrackEvent.DownloadPublicCollection, { + org_slug: this.orgSlug, + collection_slug: this.collection?.slug, + }); + }} + > + + + ` + : nothing, + )} + `; } private renderDialog() { - const showSettings = !this.navigate.isPublicPage && this.authState; - return html` { this.showDialog = false; }} - @sl-after-hide=${() => { - this.tabGroup?.show(Tab.Link); - }} - class="[--width:40rem] [--body-spacing:0]" + class="[--body-spacing:0] [--width:40rem]" > - - ${showSettings ? msg("Link Settings") : msg("Link")} - ${msg("Embed")} - - -
- ${when( - showSettings && this.collection, - this.renderSettings, - this.renderShareLink, - )} -
-
- - -
${this.renderEmbedCode()}
-
-
+
+ ${this.renderShareLink()} +
+ ${this.renderEmbedCode()} +
(this.showDialog = false)}> @@ -279,139 +152,6 @@ export class ShareCollection extends BtrixElement { `; } - private readonly renderSettings = (collection: Partial) => { - return html` -
- { - void this.updateVisibility( - (e.target as SelectCollectionAccess).value, - ); - }} - > - ${when( - this.org && - !this.org.enablePublicProfile && - this.collection?.access === CollectionAccess.Public, - () => html` - - ${alerts.orgNotPublicWarning} - - `, - )} -
- ${when( - this.collection?.access != CollectionAccess.Private, - () => html`
${this.renderShareLink()}
`, - )} -
-
- ${msg("Thumbnail")} - - - -
- ${this.renderThumbnails()} -
-
-
- ${msg("Downloads")} - - - -
-
- { - void this.updateAllowDownload((e.target as SlSwitch).checked); - }} - >${msg("Show download button")} -
-
- `; - }; - - private renderThumbnails() { - let selectedImgSrc = DEFAULT_THUMBNAIL_VARIANT.path; - - if (this.collection?.defaultThumbnailName) { - const { defaultThumbnailName } = this.collection; - const variant = Object.entries(CollectionThumbnail.Variants).find( - ([name]) => name === defaultThumbnailName, - ); - - if (variant) { - selectedImgSrc = variant[1].path; - } - } else if (this.collection?.thumbnail) { - selectedImgSrc = this.collection.thumbnail.path; - } - - const thumbnail = ( - thumbnail: Thumbnail | NonNullable, - ) => { - let name: Thumbnail | null = null; - let path = ""; - - if (Object.values(Thumbnail).some((t) => t === thumbnail)) { - name = thumbnail as Thumbnail; - path = CollectionThumbnail.Variants[name].path; - } else { - path = (thumbnail as NonNullable).path; - } - - if (!path) { - console.debug("no path for thumbnail:", thumbnail); - return; - } - - const isSelected = path === selectedImgSrc; - - return html` - - - - `; - }; - - return html` -
- ${when(this.collection?.thumbnail, (t) => thumbnail(t))} - ${thumbnail(Thumbnail.Cyan)} ${thumbnail(Thumbnail.Green)} - ${thumbnail(Thumbnail.Yellow)} ${thumbnail(Thumbnail.Orange)} -
- `; - } - private readonly renderShareLink = () => { return html`
@@ -499,142 +239,4 @@ export class ShareCollection extends BtrixElement {

`; }; - - private async updateVisibility(access: CollectionAccess) { - const prevValue = this.collection?.access; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, access }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ access }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-visibility-update-status", - message: msg("Collection visibility updated."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { ...this.collection, access: prevValue }; - } - - this.notify.toast({ - id: "collection-visibility-update-status", - message: msg("Sorry, couldn't update visibility at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } - - async updateThumbnail({ - defaultThumbnailName, - }: { - defaultThumbnailName: Thumbnail | null; - }) { - const prevValue = this.collection?.defaultThumbnailName; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, defaultThumbnailName }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ defaultThumbnailName }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-thumbnail-update-status", - message: msg("Thumbnail updated."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { - ...this.collection, - defaultThumbnailName: prevValue, - }; - } - - this.notify.toast({ - id: "collection-thumbnail-update-status", - message: msg("Sorry, couldn't update thumbnail at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } - - async updateAllowDownload(allowPublicDownload: boolean) { - const prevValue = this.collection?.allowPublicDownload; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, allowPublicDownload }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ allowPublicDownload }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-allow-public-download-update-status", - message: allowPublicDownload - ? msg("Download button enabled.") - : msg("Download button hidden."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { - ...this.collection, - allowPublicDownload: prevValue, - }; - } - - this.notify.toast({ - id: "collection-allow-public-download-update-status", - message: msg("Sorry, couldn't update download button at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index de489f9cab..dfc1693359 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,5 +1,6 @@ import "./utils/polyfills"; +import { provide } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import type { SlDialog, @@ -22,6 +23,7 @@ import "./assets/fonts/Inter/inter.css"; import "./assets/fonts/Recursive/recursive.css"; import "./styles.css"; +import { viewStateContext } from "./context/view-state"; import { OrgTab, RouteNamespace, ROUTES } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; import { pageView, type AnalyticsTrackProps } from "./utils/analytics"; @@ -98,6 +100,7 @@ export class App extends BtrixElement { @state() private translationReady = false; + @provide({ context: viewStateContext }) @state() private viewState!: ViewState; diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index 028f92c684..ae47dd1979 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -86,11 +86,17 @@ export function pageBack({ href, content }: Breadcrumb) { }); } -export function pageTitle(title?: string | TemplateResult | typeof nothing) { +export function pageTitle( + title?: string | TemplateResult | typeof nothing, + skeletonClass?: string, +) { return html`

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

`; } @@ -136,7 +142,7 @@ export function pageHeader({
${actions - ? html`
+ ? html`
${actions}
` : nothing} diff --git a/frontend/src/layouts/pageSectionsWithNav.ts b/frontend/src/layouts/pageSectionsWithNav.ts index 44c7db903f..577464ae6b 100644 --- a/frontend/src/layouts/pageSectionsWithNav.ts +++ b/frontend/src/layouts/pageSectionsWithNav.ts @@ -27,10 +27,11 @@ export function pageSectionsWithNav({ sticky && tw`lg:sticky lg:top-2 lg:self-start`, placement === "start" ? tw`lg:max-w-[16.5rem]` : tw`lg:flex-row`, )} + part="tabs" > ${nav}
-
${main}
+
${main}
`; } diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts index 5d51379e83..d2307d9a62 100644 --- a/frontend/src/pages/collections/collection.ts +++ b/frontend/src/pages/collections/collection.ts @@ -104,26 +104,26 @@ export class Collection extends BtrixElement { : [], title: collection.name || "", actions: html` - ${when( - this.canEditCollection, - () => html` - - - - `, - )} - + ${when( + this.canEditCollection, + () => html` + + ${msg("Go to Private Page")} + + `, + )} `, }; @@ -227,7 +227,7 @@ export class Collection extends BtrixElement {
-

${msg("Metadata")}

+

${msg("Details")}

${metadata}
diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index 3a51731165..b6b0694718 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -1,3 +1,4 @@ +import { consume } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import clsx from "clsx"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; @@ -12,13 +13,16 @@ import type { Embed as ReplayWebPage } from "replaywebpage"; import { BtrixElement } from "@/classes/BtrixElement"; import type { MarkdownEditor } from "@/components/ui/markdown-editor"; import type { PageChangeEvent } from "@/components/ui/pagination"; +import { viewStateContext, type ViewStateContext } from "@/context/view-state"; +import type { EditDialogTab } from "@/features/collections/collection-edit-dialog"; +import { collectionShareLink } from "@/features/collections/helpers/share-link"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import type { ShareCollection } from "@/features/collections/share-collection"; import { metadataColumn, metadataItemWithCollection, } from "@/layouts/collections/metadataColumn"; -import { pageHeader, pageNav, type Breadcrumb } from "@/layouts/pageHeader"; +import { pageNav, pageTitle, type Breadcrumb } from "@/layouts/pageHeader"; import type { APIPaginatedList, APIPaginationQuery, @@ -60,11 +64,10 @@ export class CollectionDetail extends BtrixElement { private archivedItems?: APIPaginatedList; @state() - private openDialogName?: - | "delete" - | "editMetadata" - | "editItems" - | "editStartPage"; + private openDialogName?: "delete" | "edit" | "replaySettings" | "editItems"; + + @state() + private editTab?: EditDialogTab; @state() private isEditingDescription = false; @@ -72,6 +75,9 @@ export class CollectionDetail extends BtrixElement { @state() private isRwpLoaded = false; + @consume({ context: viewStateContext }) + viewState?: ViewStateContext; + @query("replay-web-page") private readonly replayEmbed?: ReplayWebPage | null; @@ -102,6 +108,14 @@ export class CollectionDetail extends BtrixElement { }, }; + private get shareLink() { + return collectionShareLink( + this.collection, + this.orgSlugState, + this.viewState?.params.slug || "", + ); + } + private get isCrawler() { return this.appState.isCrawler; } @@ -135,29 +149,83 @@ export class CollectionDetail extends BtrixElement { } render() { - return html`
${this.renderBreadcrumbs()}
- ${pageHeader({ - title: this.collection?.name, - border: false, - prefix: this.renderAccessIcon(), - secondary: this.collection?.caption - ? html`
- ${this.collection.caption} -
` - : nothing, - actions: html` + return html` +
+ ${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.renderAccessIcon()}${pageTitle( + this.collection?.name, + tw`mb-2 h-6 w-60`, + )} + ${this.collection && + html` { + this.openDialogName = "edit"; + this.editTab = "general"; + }} + >`} +
+ ${this.collection + ? this.collection.caption + ? html`
+ ${this.collection.caption} +
` + : html`
{ + this.openDialogName = "edit"; + this.editTab = "general"; + }} + > + ${msg("Add a summary...")} +
` + : html``} +
+ +
{ e.stopPropagation(); void this.fetchCollection(); }} > ${when(this.isCrawler, this.renderActions)} - `, - })} +
+
${this.renderInfoBar()} @@ -177,15 +245,20 @@ export class CollectionDetail extends BtrixElement { > (this.openDialogName = "editStartPage")} + @click=${() => { + this.openDialogName = "replaySettings"; + }} ?disabled=${!this.collection?.crawlCount || !this.isRwpLoaded} > ${!this.collection || Boolean(this.collection.crawlCount && !this.isRwpLoaded) ? html`` - : html``} - ${msg("Configure View")} + : html``} + ${msg("Set Initial View")} `, @@ -256,7 +329,7 @@ export class CollectionDetail extends BtrixElement { { // Don't do full refresh of rwp so that rwp-url-change fires this.isRwpLoaded = false; @@ -267,23 +340,29 @@ export class CollectionDetail extends BtrixElement { collectionId=${this.collectionId} .collection=${this.collection} ?replayLoaded=${this.isRwpLoaded} - > + > + - ${when( - this.collection, - () => html` - (this.openDialogName = undefined)} - @btrix-collection-saved=${() => { - this.refreshReplay(); - void this.fetchCollection(); - }} - > - - `, - )}`; + (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-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} + > + `; } private renderAccessIcon() { @@ -390,15 +469,22 @@ export class CollectionDetail extends BtrixElement { const authToken = this.authState?.headers.Authorization.split(" ")[1]; return html` + + { + this.openDialogName = "edit"; + this.editTab = "general"; + }} + > + + + ${msg("Actions")} - (this.openDialogName = "editMetadata")}> - - ${msg("Edit Metadata")} - { // replay-web-page needs to be available in order to configure start page @@ -409,37 +495,51 @@ export class CollectionDetail extends BtrixElement { await this.updateComplete; } - this.openDialogName = "editStartPage"; + this.openDialogName = "edit"; }} ?disabled=${!this.collection?.crawlCount} > - ${msg("Configure Replay View")} + ${msg("Edit Collection Settings")} + + { + this.openDialogName = "replaySettings"; + }} + ?disabled=${!this.collection?.crawlCount || !this.isRwpLoaded} + > + ${!this.collection || + Boolean(this.collection.crawlCount && !this.isRwpLoaded) + ? html`` + : html``} + ${msg("Set Initial View")} + + { - if (this.collectionTab !== Tab.About) { - this.navigate.to( - `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.About}`, - ); - await this.updateComplete; - } - + this.navigate.to( + `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.About}`, + ); this.isEditingDescription = true; + await this.updateComplete; + await this.descriptionEditor?.updateComplete; + void this.descriptionEditor?.focus(); }} > - - ${msg("Edit About Section")} + + ${msg("Edit Description")} (this.openDialogName = "editItems")}> ${msg("Select Archived Items")} - this.shareCollection?.show()}> - - ${msg("Share Collection")} - html` - +
-

${msg("Metadata")}

+

${msg("Details")}

${metadata}
diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 567dafec5c..f240e7846a 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -14,7 +14,7 @@ import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { ClipboardController } from "@/controllers/clipboard"; -import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog"; +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"; @@ -107,7 +107,7 @@ export class CollectionsList extends BtrixElement { private searchResultsOpen = false; @state() - private openDialogName?: "create" | "delete" | "editMetadata"; + private openDialogName?: "create" | "delete" | "edit"; @state() private isDialogVisible = false; @@ -158,7 +158,9 @@ export class CollectionsList extends BtrixElement { variant="primary" size="small" ?disabled=${!this.org || this.org.readOnly} - @click=${() => (this.openDialogName = "create")} + @click=${() => { + this.openDialogName = "create"; + }} > ${msg("New Collection")} @@ -220,14 +222,8 @@ export class CollectionsList extends BtrixElement { )}
- (this.openDialogName = undefined)} @sl-after-hide=${() => (this.selectedCollection = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { @@ -240,7 +236,20 @@ export class CollectionsList extends BtrixElement { } }} > - + + { + this.openDialogName = undefined; + }} + @sl-after-hide=${() => { + this.selectedCollection = undefined; + }} + @btrix-collection-saved=${() => { + void this.fetchCollections(); + }} + > `; } @@ -572,84 +581,30 @@ export class CollectionsList extends BtrixElement { return html` - void this.manageCollection(col, "editMetadata")} - > - - ${msg("Edit Metadata")} + void this.manageCollection(col, "edit")}> + + ${msg("Edit Collection Settings")} - ${col.access === CollectionAccess.Private + ${col.access === CollectionAccess.Public || + col.access === CollectionAccess.Unlisted ? html` - - void this.updateAccess(col, CollectionAccess.Unlisted)} - > - - ${msg("Enable Share Link")} - - ` - : html` { ClipboardController.copyToClipboard(this.getShareLink(col)); this.notify.toast({ message: msg("Link copied"), + variant: "success", + icon: "check2-circle", }); }} > ${msg("Copy Share Link")} - ${col.access === CollectionAccess.Public - ? html` - - void this.updateAccess( - col, - CollectionAccess.Unlisted, - )} - > - - ${msg("Make Unlisted")} - - ` - : this.org?.enablePublicProfile - ? html` - - void this.updateAccess( - col, - CollectionAccess.Public, - )} - > - - ${msg("Make Public")} - - ` - : nothing} - - void this.updateAccess(col, CollectionAccess.Private)} - > - - ${msg("Make Private")} - - `} + ` + : nothing} [this.orgId] as const, @@ -89,11 +93,47 @@ export class Dashboard extends BtrixElement { return html` ${pageHeader({ title: this.userOrg?.name, + secondary: html` + ${when( + this.org?.publicDescription, + (publicDescription) => html` +
${publicDescription}
+ `, + )} + ${when(this.org?.publicUrl, (urlStr) => { + let url: URL; + try { + url = new URL(urlStr); + } catch { + return nothing; + } + + return html` + + `; + })} + `, actions: html` ${when( this.appState.isAdmin, () => - html` + html` html` - - - + ${when( + this.appState.isCrawler, + () => html` + + ${when(this.org, (org) => + org.enablePublicProfile + ? html` ` + : nothing, + )} + + - - ${msg("Manage Collections")} - - - - ${this.org?.enablePublicProfile - ? msg("Visit Public Collections Gallery") - : msg("Preview Public Collections Gallery")} - - ${when(this.org, (org) => - org.enablePublicProfile - ? html` - { - ClipboardController.copyToClipboard( - `${window.location.protocol}//${window.location.hostname}${ - window.location.port - ? `:${window.location.port}` - : "" - }/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`, - ); - this.notify.toast({ - message: msg("Link copied"), - }); - }} - > - - ${msg("Copy Link to Public Gallery")} - - ` - : this.appState.isAdmin - ? html` - - - - ${msg("Update Org Profile")} - - ` - : nothing, - )} - - - `, - )} + class="size-8 text-base" + name="collection" + @click=${this.navigate.link} + > + + `, + )} + + + +
{ + this.collectionRefreshing = e.detail.id; + void this.publicCollections.run([this.orgId]); + }} > ${this.renderNoPublicCollections()} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index cc68523188..3b0191a2a7 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -20,7 +20,7 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { proxiesContext, type ProxiesContext } from "@/context/org"; import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; -import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog"; +import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { OrgTab, RouteNamespace } from "@/routes"; import type { ProxiesAPIResponse } from "@/types/crawler"; @@ -281,7 +281,7 @@ export class Org extends BtrixElement {
${when(this.userOrg, (userOrg) => @@ -445,7 +445,7 @@ export class Org extends BtrixElement { @sl-hide=${() => (this.openDialogName = undefined)} > - (this.openDialogName = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { @@ -454,7 +454,7 @@ export class Org extends BtrixElement { ); }} > - +
`; } diff --git a/frontend/src/pages/org/settings/components/general.ts b/frontend/src/pages/org/settings/components/general.ts index 996843e709..dea9cc1007 100644 --- a/frontend/src/pages/org/settings/components/general.ts +++ b/frontend/src/pages/org/settings/components/general.ts @@ -119,7 +119,7 @@ export class OrgSettingsGeneral extends BtrixElement { ${this.org?.enablePublicProfile ? html`
  • - ${msg("Public gallery")}: + ${msg("Public collections gallery")}: /${RouteNamespace.PublicOrgs}/ - ${msg("View public gallery")} + ${msg("View public collections gallery")} `, )} @@ -183,7 +183,7 @@ export class OrgSettingsGeneral extends BtrixElement { return html` - ${msg("Public Gallery")} + ${msg("Public Collections Gallery")} ${columns([ [ @@ -199,7 +199,11 @@ export class OrgSettingsGeneral extends BtrixElement { ${msg("Enable public collections gallery")} - + `, - msg( - "Link to your organization's (or your personal) website in the public gallery.", - ), + msg("Link to your organization's (or your personal) website."), ], ])} `; diff --git a/frontend/src/pages/org/workflow-detail.ts b/frontend/src/pages/org/workflow-detail.ts index 8e096cd2ec..8724a77037 100644 --- a/frontend/src/pages/org/workflow-detail.ts +++ b/frontend/src/pages/org/workflow-detail.ts @@ -504,15 +504,17 @@ export class WorkflowDetail extends BtrixElement { } if (this.activePanel === "settings" && this.isCrawler) { return html`

    ${this.tabLabels[this.activePanel]}

    - - this.navigate.to( - `/orgs/${this.appState.orgSlug}/workflows/${this.workflow?.id}?edit`, - )} - > - `; + + + this.navigate.to( + `/orgs/${this.appState.orgSlug}/workflows/${this.workflow?.id}?edit`, + )} + > + +
    `; } if (this.activePanel === "watch" && this.isCrawler) { const enableEditBrowserWindows = diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts index 4809ace754..2770a89ab1 100644 --- a/frontend/src/pages/public/org.ts +++ b/frontend/src/pages/public/org.ts @@ -129,15 +129,24 @@ export class PublicOrg extends BtrixElement { : nothing, actions: when( this.canEditOrg, - () => - html` + () => html` + + + + - `, + + `, ), secondary: html` ${when( @@ -190,11 +199,11 @@ export class PublicOrg extends BtrixElement { ${when( this.canEditOrg, () => - html` + html` `, diff --git a/frontend/src/types/collection.ts b/frontend/src/types/collection.ts index 4ec6f8fe6c..8e148e5188 100644 --- a/frontend/src/types/collection.ts +++ b/frontend/src/types/collection.ts @@ -6,6 +6,16 @@ export enum CollectionAccess { Unlisted = "unlisted", } +export const collectionThumbnailSourceSchema = z.object({ + url: z.string().url(), + urlPageId: z.string().url(), + urlTs: z.string().datetime(), +}); + +export type CollectionThumbnailSource = z.infer< + typeof collectionThumbnailSourceSchema +>; + export const publicCollectionSchema = z.object({ id: z.string(), slug: z.string(), @@ -24,6 +34,7 @@ export const publicCollectionSchema = z.object({ path: z.string().url(), }) .nullable(), + thumbnailSource: collectionThumbnailSourceSchema.nullable(), defaultThumbnailName: z.string().nullable(), crawlCount: z.number(), uniquePageCount: z.number(), @@ -33,6 +44,7 @@ export const publicCollectionSchema = z.object({ homeUrl: z.string().url().nullable(), homeUrlPageId: z.string().url().nullable(), homeUrlTs: z.string().datetime().nullable(), + access: z.nativeEnum(CollectionAccess), }); export type PublicCollection = z.infer; @@ -45,3 +57,18 @@ export type Collection = z.infer; export type CollectionSearchValues = { names: string[]; }; + +export const collectionUpdateSchema = z + .object({ + slug: z.string(), + name: z.string(), + description: z.string(), + caption: z.string(), + access: z.string(), + defaultThumbnailName: z.string().nullable(), + allowPublicDownload: z.boolean(), + thumbnailSource: collectionThumbnailSourceSchema.nullable(), + }) + .partial(); + +export type CollectionUpdate = z.infer; diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index fde88e1827..93db4d1bd7 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -7,7 +7,7 @@ import localize from "./localize"; export type MaxLengthValidator = { helpText: string; - validate: (e: CustomEvent) => void; + validate: (e: CustomEvent) => boolean; }; export function getHelpText(maxLength: number, currentLength: number) { @@ -58,6 +58,7 @@ export function maxLengthValidator(maxLength: number): MaxLengthValidator { ); el.helpText = isInvalid ? validityText : origHelpText || validityHelpText; + return !isInvalid; }; return { helpText: validityHelpText, validate };