From b7f8ac11c3e10700ed945f320e499fe373ce7541 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 3 Dec 2024 19:45:52 -0800 Subject: [PATCH] feat: Make collection public (#2208) --- frontend/package.json | 2 +- frontend/src/components/ui/copy-button.ts | 53 +-- frontend/src/components/ui/dialog.ts | 19 +- frontend/src/controllers/clipboard.ts | 61 ++++ .../collections/collection-metadata-dialog.ts | 38 +- frontend/src/features/collections/index.ts | 2 + .../collections/select-collection-access.ts | 95 +++++ .../features/collections/share-collection.ts | 324 ++++++++++++++++++ frontend/src/index.ts | 48 +-- frontend/src/layouts/page.ts | 52 +++ frontend/src/layouts/pageHeader.ts | 3 +- frontend/src/pages/collections/collection.ts | 212 ++++++++++++ frontend/src/pages/collections/index.ts | 1 + frontend/src/pages/index.ts | 1 + .../archived-item-detail.ts | 7 +- frontend/src/pages/org/archived-items.ts | 13 +- frontend/src/pages/org/collection-detail.ts | 288 +++++----------- frontend/src/pages/org/collections-list.ts | 101 ++++-- frontend/src/pages/org/profile.ts | 174 +++++----- frontend/src/pages/org/workflow-detail.ts | 5 +- frontend/src/pages/org/workflows-list.ts | 5 +- frontend/src/routes.ts | 1 + frontend/src/theme.stylesheet.css | 12 + frontend/src/types/events.d.ts | 4 +- frontend/src/types/org.ts | 22 ++ frontend/xliff/es.xlf | 112 ++++-- frontend/yarn.lock | 8 +- 27 files changed, 1185 insertions(+), 478 deletions(-) create mode 100644 frontend/src/controllers/clipboard.ts create mode 100644 frontend/src/features/collections/select-collection-access.ts create mode 100644 frontend/src/features/collections/share-collection.ts create mode 100644 frontend/src/layouts/page.ts create mode 100644 frontend/src/pages/collections/collection.ts create mode 100644 frontend/src/pages/collections/index.ts diff --git a/frontend/package.json b/frontend/package.json index 312a06927a..e92991c57d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "@prettier/plugin-xml": "^3.4.1", "@rollup/plugin-commonjs": "^18.0.0", "@shoelace-style/localize": "^3.2.1", - "@shoelace-style/shoelace": "~2.15.1", + "@shoelace-style/shoelace": "~2.18.0", "@tailwindcss/container-queries": "^0.1.1", "@types/color": "^3.0.2", "@types/diff": "^5.0.9", diff --git a/frontend/src/components/ui/copy-button.ts b/frontend/src/components/ui/copy-button.ts index 84b0c6fb64..85028b87c8 100644 --- a/frontend/src/components/ui/copy-button.ts +++ b/frontend/src/components/ui/copy-button.ts @@ -1,8 +1,9 @@ import { localized, msg } from "@lit/localize"; import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { TailwindElement } from "@/classes/TailwindElement"; +import { ClipboardController } from "@/controllers/clipboard"; /** * Copy text to clipboard on click @@ -16,7 +17,7 @@ import { TailwindElement } from "@/classes/TailwindElement"; * value}> * ``` * - * @event on-copied + * @fires btrix-copied */ @localized() @customElement("btrix-copy-button") @@ -42,31 +43,17 @@ export class CopyButton extends TailwindElement { @property({ type: String }) size: "x-small" | "small" | "medium" = "small"; - @state() - private isCopied = false; - - timeoutId?: number; - - static copyToClipboard(value: string) { - void navigator.clipboard.writeText(value); - } - - disconnectedCallback() { - window.clearTimeout(this.timeoutId); - super.disconnectedCallback(); - } + private readonly clipboardController = new ClipboardController(this); render() { return html` @@ -87,25 +78,7 @@ export class CopyButton extends TailwindElement { private onClick() { const value = (this.getValue ? this.getValue() : this.value) || ""; - CopyButton.copyToClipboard(value); - - this.isCopied = true; - - this.dispatchEvent(new CustomEvent("on-copied", { detail: value })); - - this.timeoutId = window.setTimeout(() => { - this.isCopied = false; - const button = this.shadowRoot?.querySelector("sl-icon-button"); - button?.blur(); // Remove focus from the button to set it back to its default state - }, 3000); - } - /** - * Stop propgation of sl-tooltip events. - * Prevents bug where sl-dialog closes when tooltip closes - * https://github.com/shoelace-style/shoelace/issues/170 - */ - private stopProp(e: Event) { - e.stopPropagation(); + void this.clipboardController.copy(value); } } diff --git a/frontend/src/components/ui/dialog.ts b/frontend/src/components/ui/dialog.ts index c74e558592..5bd89544b4 100644 --- a/frontend/src/components/ui/dialog.ts +++ b/frontend/src/components/ui/dialog.ts @@ -69,17 +69,20 @@ export class Dialog extends SlDialog { // optionally re-emitting them as "sl-inner-hide" events protected createRenderRoot() { const root = super.createRenderRoot(); - root.addEventListener("sl-hide", (event: Event) => { - if (!(event.target instanceof Dialog)) { - event.stopPropagation(); - if (this.reEmitInnerSlHideEvents) { - this.dispatchEvent(new CustomEvent("sl-inner-hide", { ...event })); - } - } - }); + root.addEventListener("sl-hide", this.handleSlEvent); + root.addEventListener("sl-after-hide", this.handleSlEvent); return root; } + private readonly handleSlEvent = (event: Event) => { + if (!(event.target instanceof Dialog)) { + event.stopPropagation(); + if (this.reEmitInnerSlHideEvents) { + this.dispatchEvent(new CustomEvent("sl-inner-hide", { ...event })); + } + } + }; + /** * Submit form using external buttons to bypass * incorrect `getRootNode` in Chrome. diff --git a/frontend/src/controllers/clipboard.ts b/frontend/src/controllers/clipboard.ts new file mode 100644 index 0000000000..402baaea69 --- /dev/null +++ b/frontend/src/controllers/clipboard.ts @@ -0,0 +1,61 @@ +import { msg } from "@lit/localize"; +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +export type CopiedEventDetail = string; + +export interface CopiedEventMap { + "btrix-copied": CustomEvent; +} + +/** + * Copy to clipboard + * + * @fires btrix-copied + */ +export class ClipboardController implements ReactiveController { + static readonly text = { + copy: msg("Copy"), + copied: msg("Copied"), + }; + + static copyToClipboard(value: string) { + void navigator.clipboard.writeText(value); + } + + private readonly host: ReactiveControllerHost & EventTarget; + + private timeoutId?: number; + + isCopied = false; + + constructor(host: ClipboardController["host"]) { + this.host = host; + host.addController(this); + } + + hostConnected() {} + + hostDisconnected() { + window.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + async copy(value: string) { + ClipboardController.copyToClipboard(value); + + this.isCopied = true; + + this.timeoutId = window.setTimeout(() => { + this.isCopied = false; + this.host.requestUpdate(); + }, 3000); + + this.host.requestUpdate(); + + await this.host.updateComplete; + + this.host.dispatchEvent( + new CustomEvent("btrix-copied", { detail: value }), + ); + } +} diff --git a/frontend/src/features/collections/collection-metadata-dialog.ts b/frontend/src/features/collections/collection-metadata-dialog.ts index a5642ae6d4..329921ab63 100644 --- a/frontend/src/features/collections/collection-metadata-dialog.ts +++ b/frontend/src/features/collections/collection-metadata-dialog.ts @@ -14,6 +14,7 @@ import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; import type { MarkdownEditor } from "@/components/ui/markdown-editor"; +import type { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import { CollectionAccess, type Collection } from "@/types/collection"; import { isApiError } from "@/utils/api"; import { maxLengthValidator } from "@/utils/form"; @@ -43,10 +44,20 @@ export class CollectionMetadataDialog extends BtrixElement { @query("btrix-markdown-editor") private readonly descriptionEditor?: MarkdownEditor | null; + @query("btrix-select-collection-access") + private readonly selectCollectionAccess?: SelectCollectionAccess | null; + @queryAsync("#collectionForm") private readonly form!: Promise; private readonly validateNameMax = maxLengthValidator(50); + + protected firstUpdated(): void { + if (this.open) { + this.isDialogVisible = true; + } + } + render() { return html` html` - + `, )} @@ -172,7 +167,7 @@ export class CollectionMetadataDialog extends BtrixElement { return; } - const { name, isPublic } = serialize(form); + const { name } = serialize(form); const description = this.descriptionEditor.value; this.isSubmitting = true; @@ -180,9 +175,10 @@ export class CollectionMetadataDialog extends BtrixElement { const body = JSON.stringify({ name, description, - access: !isPublic - ? CollectionAccess.Private - : CollectionAccess.Unlisted, + access: + this.selectCollectionAccess?.value || + this.collection?.access || + CollectionAccess.Private, }); let path = `/orgs/${this.orgId}/collections`; let method = "POST"; diff --git a/frontend/src/features/collections/index.ts b/frontend/src/features/collections/index.ts index 228e669a24..949a13dbc5 100644 --- a/frontend/src/features/collections/index.ts +++ b/frontend/src/features/collections/index.ts @@ -2,3 +2,5 @@ import("./collections-add"); import("./collection-items-dialog"); import("./collection-metadata-dialog"); import("./collection-workflow-list"); +import("./select-collection-access"); +import("./share-collection"); diff --git a/frontend/src/features/collections/select-collection-access.ts b/frontend/src/features/collections/select-collection-access.ts new file mode 100644 index 0000000000..b94686c1d9 --- /dev/null +++ b/frontend/src/features/collections/select-collection-access.ts @@ -0,0 +1,95 @@ +import { localized, msg } from "@lit/localize"; +import type { SlIcon, SlSelectEvent } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { CollectionAccess } from "@/types/collection"; + +@localized() +@customElement("btrix-select-collection-access") +export class SelectCollectionAccess extends BtrixElement { + static readonly Options: Record< + CollectionAccess, + { label: string; icon: NonNullable; detail: string } + > = { + [CollectionAccess.Private]: { + label: msg("Private"), + icon: "lock", + detail: msg("Only org members can view"), + }, + [CollectionAccess.Unlisted]: { + label: msg("Unlisted"), + icon: "link-45deg", + detail: msg("Only people with the link can view"), + }, + + [CollectionAccess.Public]: { + label: msg("Public"), + icon: "globe2", + detail: msg("Anyone can view on the org's public profile"), + }, + }; + + @property({ type: String }) + value: CollectionAccess = CollectionAccess.Private; + + @property({ type: Boolean }) + readOnly = false; + + render() { + const selected = SelectCollectionAccess.Options[this.value]; + + if (this.readOnly) { + return html` + + + ${selected.detail} + + `; + } + + return html` +
+
+ ${msg("Visibility")} +
+ { + const { value } = e.detail.item; + this.value = value as CollectionAccess; + }} + > + + + ${selected.label} +
${selected.detail}
+
+ + ${Object.entries(SelectCollectionAccess.Options).map( + ([value, { label, icon, detail }]) => html` + + + ${label} + ${detail} + + `, + )} + +
+
+ `; + } +} diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts new file mode 100644 index 0000000000..959f7096e2 --- /dev/null +++ b/frontend/src/features/collections/share-collection.ts @@ -0,0 +1,324 @@ +import { localized, msg, str } from "@lit/localize"; +import type { SlSelectEvent } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { SelectCollectionAccess } from "./select-collection-access"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { ClipboardController } from "@/controllers/clipboard"; +import { RouteNamespace } from "@/routes"; +import { CollectionAccess, type Collection } from "@/types/collection"; + +export type SelectVisibilityDetail = { + item: { value: CollectionAccess }; +}; + +/** + * @fires btrix-select + */ +@localized() +@customElement("btrix-share-collection") +export class ShareCollection extends BtrixElement { + @property({ type: String }) + collectionId = ""; + + @property({ type: Object }) + collection?: Partial; + + @state() + private showDialog = false; + + @state() + private showEmbedCode = false; + + 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.orgSlug}/collections/view` : `${RouteNamespace.PublicOrgs}/${this.orgSlug}/collections`}/${this.collectionId}`; + } + return ""; + } + + private get publicReplaySrc() { + return new URL( + `/api/orgs/${this.collection?.oid}/collections/${this.collectionId}/public/replay.json`, + window.location.href, + ).href; + } + + public show() { + this.showDialog = true; + } + + render() { + return html` ${this.renderButton()} ${this.renderDialog()}`; + } + + private renderButton() { + if (!this.collection) { + return html` + + `; + } + + if (this.collection.access === CollectionAccess.Private) { + return html` + (this.showDialog = true)} + > + + ${msg("Share")} + + `; + } + + return html` + + + { + void this.clipboardController.copy(this.shareLink); + }} + > + + + ${msg("Copy Link")} + + + + + + + { + this.showEmbedCode = true; + this.showDialog = true; + }} + > + + ${msg("View Embed Code")} + + ${when( + this.authState && + this.collectionId && + this.shareLink !== + window.location.href.slice( + 0, + window.location.href.indexOf(this.collectionId) + + this.collectionId.length, + ), + () => html` + + ${this.collection?.access === CollectionAccess.Unlisted + ? html` + + ${msg("Visit Unlisted Page")} + ` + : html` + + ${msg("Visit Public Page")} + `} + + + { + this.showDialog = true; + }} + > + + ${msg("Change Link Visibility")} + + `, + () => html` + + + ${msg("Download Collection")} + + `, + )} + + + + `; + } + + private renderDialog() { + return html` + { + this.showDialog = false; + }} + @sl-after-hide=${() => { + this.showEmbedCode = false; + }} + style="--width: 32rem;" + > + ${when( + this.authState && this.collection, + (collection) => html` +
+ { + this.dispatchEvent( + new CustomEvent("btrix-select", { + detail: { + item: { + value: (e.target as SelectCollectionAccess).value, + }, + }, + }), + ); + }} + > +
+ `, + )} + ${this.renderShareLink()} ${this.renderEmbedCode()} +
+ (this.showDialog = false)}> + ${msg("Done")} + +
+
+ `; + } + + 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` + + ${msg("Embed Code")} + ${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/index.ts b/frontend/src/index.ts index adc22dd53c..1630439761 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -6,6 +6,7 @@ import type { } from "@shoelace-style/shoelace"; import { html, nothing, render, type TemplateResult } 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 isEqual from "lodash/fp/isEqual"; @@ -477,7 +478,7 @@ export class App extends BtrixElement { ` : html` - ${this.viewState.route === "publicOrgProfile" + ${this.viewState.route !== "login" ? html` - ${msg("Sign Up")} - - `; - } - - if (signUpUrl) { - return html` - - ${msg("Sign Up")} - - `; - } - } - private renderOrgs() { const orgs = this.userInfo?.orgs; if (!orgs) return; @@ -719,6 +691,7 @@ export class App extends BtrixElement { `; } + // TODO move into separate component private renderPage() { switch (this.viewState.route) { case "signUp": { @@ -802,6 +775,21 @@ export class App extends BtrixElement { slug=${this.viewState.params.slug} >`; + case "publicCollection": { + const { collectionId, collectionTab } = this.viewState.params; + + if (!collectionId) { + break; + } + + return html``; + } + case "accountSettings": return html` +
+
${pageTitle(title)} ${suffix}
+ ${actions + ? html`
${actions}
` + : nothing} +
+ ${secondary} + + `; +} + +export function page( + header: Parameters[0], + render: () => TemplateResult, +) { + return html` + +
+ ${pageHeader(header)} +
${render()}
+
`; +} diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index c0f83829e5..f405a6d919 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -84,7 +84,7 @@ export function pageBack({ href, content }: Breadcrumb) { }); } -export function pageTitle(title?: string | TemplateResult) { +export function pageTitle(title?: string | TemplateResult | typeof nothing) { return html`

${title || html``} @@ -100,6 +100,7 @@ export function pageNav(breadcrumbs: Breadcrumb[]) { return pageBreadcrumbs(breadcrumbs); } +// TODO consolidate with page.ts https://github.com/webrecorder/browsertrix/issues/2197 export function pageHeader( title?: string | TemplateResult, suffix?: TemplateResult<1>, diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts new file mode 100644 index 0000000000..ab1fdcb370 --- /dev/null +++ b/frontend/src/pages/collections/collection.ts @@ -0,0 +1,212 @@ +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { choose } from "lit/directives/choose.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { SelectVisibilityDetail } from "@/features/collections/share-collection"; +import { page } from "@/layouts/page"; +import { RouteNamespace } from "@/routes"; +import type { OrgProfileData, PublicCollection } from "@/types/org"; + +enum Tab { + Replay = "replay", + About = "about", +} + +@localized() +@customElement("btrix-collection") +export class Collection extends BtrixElement { + @property({ type: String }) + slug?: string; + + @property({ type: String }) + collectionId?: string; + + @property({ type: String }) + tab: Tab | string = Tab.Replay; + + private readonly tabLabels: Record< + Tab, + { icon: { name: string; library: string }; text: string } + > = { + [Tab.Replay]: { + icon: { name: "replaywebpage", library: "app" }, + text: msg("Replay"), + }, + [Tab.About]: { + icon: { name: "info-square-fill", library: "default" }, + text: msg("About"), + }, + }; + + readonly publicOrg = new Task(this, { + task: async ([slug]) => { + if (!slug) return; + const org = await this.fetchOrgProfile(slug); + return org; + }, + args: () => [this.slug, this.collectionId] as const, + }); + + render() { + return html` + ${this.publicOrg.render({ + complete: (profile) => + profile ? this.renderCollection(profile) : nothing, + })} + `; + } + + private renderCollection({ org, collections }: OrgProfileData) { + const collection = + this.collectionId && + collections.find(({ id }) => id === this.collectionId); + + if (!collection) { + return "TODO"; + } + + return html` + ${page( + { + title: collection.name, + secondary: html` +
+ ${msg("Collection by")} + ${org.name} +
+ `, + actions: html` + ) => { + e.stopPropagation(); + console.log("TODO"); + }} + > + `, + }, + () => html` + + + ${choose( + this.tab, + [ + [Tab.Replay, () => this.renderReplay(collection)], + [Tab.About, () => this.renderAbout(collection)], + ], + () => html``, + )} + `, + )} + `; + } + + private readonly renderTab = (tab: Tab) => { + const isSelected = tab === (this.tab as Tab); + + return html` + + + ${this.tabLabels[tab].text} + `; + }; + + private renderReplay(collection: PublicCollection) { + const replaySource = new URL( + `/api/orgs/${collection.oid}/collections/${this.collectionId}/public/replay.json`, + window.location.href, + ).href; + + return html` +
+ +
+ `; + } + + private renderAbout(collection: PublicCollection) { + return html` +
+
+

+ ${msg("Description")} +

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

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

`} +
+
+
+

+ ${msg("Metadata")} +

+ + + ${this.localize.number(collection.crawlCount)} + + + ${this.localize.number(collection.pageCount)} + + + ${this.localize.bytes(collection.totalSize)} + + + TODO + + +
+
+ `; + } + + private async fetchOrgProfile(slug: string): Promise { + const resp = await fetch(`/api/public-collections/${slug}`, { + headers: { "Content-Type": "application/json" }, + }); + + switch (resp.status) { + case 200: + return (await resp.json()) as OrgProfileData; + case 404: { + throw resp.status; + } + default: + throw resp.status; + } + } +} diff --git a/frontend/src/pages/collections/index.ts b/frontend/src/pages/collections/index.ts new file mode 100644 index 0000000000..503f981a79 --- /dev/null +++ b/frontend/src/pages/collections/index.ts @@ -0,0 +1 @@ +import "./collection"; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 74ddf1566a..142bce53f1 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -10,3 +10,4 @@ import(/* webpackChunkName: "reset-password" */ "./reset-password"); import(/* webpackChunkName: "users-invite" */ "./users-invite"); import(/* webpackChunkName: "accept-invite" */ "./invite/accept"); import(/* webpackChunkName: "account-settings" */ "./account-settings"); +import(/* webpackChunkName: "collections" */ "./collections"); diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index 2553168338..5322505e2a 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -7,9 +7,9 @@ import { when } from "lit/directives/when.js"; import capitalize from "lodash/fp/capitalize"; import { BtrixElement } from "@/classes/BtrixElement"; -import { CopyButton } from "@/components/ui/copy-button"; import { type Dialog } from "@/components/ui/dialog"; import type { PageChangeEvent } from "@/components/ui/pagination"; +import { ClipboardController } from "@/controllers/clipboard"; import type { CrawlLog } from "@/features/archived-items/crawl-logs"; import { pageBack, pageNav, type Breadcrumb } from "@/layouts/pageHeader"; import type { APIPaginatedList } from "@/types/api"; @@ -666,7 +666,8 @@ export class ArchivedItemDetail extends BtrixElement { ${msg("Go to Workflow")} CopyButton.copyToClipboard(this.item?.cid || "")} + @click=${() => + ClipboardController.copyToClipboard(this.item?.cid || "")} > ${msg("Copy Workflow ID")} @@ -675,7 +676,7 @@ export class ArchivedItemDetail extends BtrixElement { )} - CopyButton.copyToClipboard(this.item!.tags.join(", "))} + ClipboardController.copyToClipboard(this.item!.tags.join(", "))} ?disabled=${!this.item.tags.length} > diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index c51bff84ef..7466ae4e87 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -11,8 +11,8 @@ import queryString from "query-string"; import type { ArchivedItem, Crawl, Workflow } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; -import { CopyButton } from "@/components/ui/copy-button"; import type { PageChangeEvent } from "@/components/ui/pagination"; +import { ClipboardController } from "@/controllers/clipboard"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { pageHeader } from "@/layouts/pageHeader"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; @@ -609,18 +609,23 @@ export class CrawlsList extends BtrixElement { ${msg("Go to Workflow")} - CopyButton.copyToClipboard(item.cid)}> + ClipboardController.copyToClipboard(item.cid)} + > ${msg("Copy Workflow ID")} - CopyButton.copyToClipboard(item.id)}> + ClipboardController.copyToClipboard(item.id)} + > ${msg("Copy Crawl ID")} ` : nothing} CopyButton.copyToClipboard(item.tags.join(", "))} + @click=${() => + ClipboardController.copyToClipboard(item.tags.join(", "))} ?disabled=${!item.tags.length} > diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index c469bc215e..f66622550a 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -1,5 +1,4 @@ import { localized, msg, str } from "@lit/localize"; -import type { SlCheckbox } from "@shoelace-style/shoelace"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; @@ -10,6 +9,11 @@ import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import type { PageChangeEvent } from "@/components/ui/pagination"; +import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; +import type { + SelectVisibilityDetail, + ShareCollection, +} from "@/features/collections/share-collection"; import { pageNav, type Breadcrumb } from "@/layouts/pageHeader"; import type { APIPaginatedList, @@ -36,9 +40,6 @@ export class CollectionDetail extends BtrixElement { @property({ type: String }) collectionTab?: Tab = TABS[0]; - @property({ type: Boolean }) - isCrawler?: boolean; - @state() private collection?: Collection; @@ -51,9 +52,6 @@ export class CollectionDetail extends BtrixElement { @state() private isDescriptionExpanded = false; - @state() - private showShareInfo = false; - @query(".description") private readonly description?: HTMLElement | null; @@ -63,6 +61,9 @@ export class CollectionDetail extends BtrixElement { @query("replay-web-page") private readonly replayEmbed?: ReplayWebPage | null; + @query("btrix-share-collection") + private readonly shareCollection?: ShareCollection | null; + // Use to cancel requests private getArchivedItemsController: AbortController | null = null; @@ -80,6 +81,10 @@ export class CollectionDetail extends BtrixElement { }, }; + private get isCrawler() { + return this.appState.isCrawler; + } + protected async willUpdate( changedProperties: PropertyValues & Map, ) { @@ -102,40 +107,73 @@ export class CollectionDetail extends BtrixElement {
- ${this.collection?.access === CollectionAccess.Unlisted - ? html` - + ${choose(this.collection?.access, [ + [ + CollectionAccess.Private, + () => html` + - ` - : html` - - + `, + ], + [ + CollectionAccess.Unlisted, + () => html` + + - `} + `, + ], + [ + CollectionAccess.Public, + () => html` + + + + `, + ], + ])}

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

- ${when( - this.isCrawler || - this.collection?.access !== CollectionAccess.Private, - () => html` - (this.showShareInfo = true)} - > - - ${msg("Share")} - - `, - )} + ) => { + e.stopPropagation(); + void this.updateVisibility(e.detail.item.value); + }} + > ${when(this.isCrawler, this.renderActions)}
@@ -227,8 +265,7 @@ export class CollectionDetail extends BtrixElement { > `, - )} - ${this.renderShareDialog()}`; + )}`; } private refreshReplay() { @@ -241,141 +278,6 @@ export class CollectionDetail extends BtrixElement { } } - private getPublicReplayURL() { - return new URL( - `/api/orgs/${this.orgId}/collections/${this.collectionId}/public/replay.json`, - window.location.href, - ).href; - } - - private renderShareDialog() { - return html` - (this.showShareInfo = false)} - style="--width: 32rem;" - > - ${ - this.collection?.access === CollectionAccess.Unlisted - ? "" - : html`

- ${msg( - "Make this collection shareable to enable a public viewing link.", - )} -

` - } - ${when( - this.isCrawler, - () => html` -
- - void this.onTogglePublic((e.target as SlCheckbox).checked)} - >${msg("Collection is Shareable")} -
- `, - )} -
- ${when(this.collection?.access === CollectionAccess.Unlisted, this.renderShareInfo)} -
- (this.showShareInfo = false)} - >${msg("Done")} -
- - `; - } - - private readonly renderShareInfo = () => { - const replaySrc = this.getPublicReplayURL(); - const encodedReplaySrc = encodeURIComponent(replaySrc); - const publicReplayUrl = `https://replayweb.page?source=${encodedReplaySrc}`; - const embedCode = ``; - const importCode = `importScripts("https://replayweb.page/sw.js");`; - - return html` ${msg("Link to Share")} -
-

- ${msg("This collection can be viewed by anyone with the link.")} -

- - - - - - - -
- ${msg("Embed Collection")} -
-

- ${msg( - html`Share this collection by embedding it into an existing webpage.`, - )} -

-

- ${msg(html`Add the following embed code to your HTML page:`)} -

-
- -
- 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.`, - )} -

-
`; - }; - private readonly renderBreadcrumbs = () => { const breadcrumbs: Breadcrumb[] = [ { @@ -432,35 +334,10 @@ export class CollectionDetail extends BtrixElement { ${msg("Select Archived Items")}
- ${this.collection?.access === CollectionAccess.Private - ? html` - void this.onTogglePublic(true)} - > - - ${msg("Make Shareable")} - - ` - : html` - - - - Visit Shareable URL - - - void this.onTogglePublic(false)} - > - - ${msg("Make Private")} - - `} + this.shareCollection?.show()}> + + ${msg("Share Collection")} + ( `/orgs/${this.orgId}/collections/${this.collectionId}`, { @@ -774,8 +648,16 @@ export class CollectionDetail extends BtrixElement { }, ); - if (res.updated && this.collection) { - this.collection = { ...this.collection, access }; + if (res.updated) { + this.notify.toast({ + message: msg("Updated collection visibility."), + variant: "success", + icon: "check2-circle", + }); + + if (this.collection) { + this.collection = { ...this.collection, access }; + } } } diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 9ec29a6ae3..4fab62944b 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -3,6 +3,7 @@ import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; import Fuse from "fuse.js"; import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { choose } from "lit/directives/choose.js"; import { guard } from "lit/directives/guard.js"; import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; @@ -12,8 +13,11 @@ 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 { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import { pageHeader } from "@/layouts/pageHeader"; +import { RouteNamespace } from "@/routes"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import { CollectionAccess, @@ -105,6 +109,10 @@ export class CollectionsList extends BtrixElement { threshold: 0.2, // stricter; default is 0.6 }); + private getShareLink(collection: Collection) { + return `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/${collection.access === CollectionAccess.Private ? `${RouteNamespace.PrivateOrgs}/${this.orgSlug}/collections/view` : `${RouteNamespace.PublicOrgs}/${this.orgSlug}/collections`}/${collection.id}`; + } + private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } @@ -502,25 +510,58 @@ export class CollectionsList extends BtrixElement { class="cursor-pointer select-none rounded border shadow transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none" > - ${col.access === CollectionAccess.Unlisted - ? html` - + ${choose(col.access, [ + [ + CollectionAccess.Private, + () => html` + - ` - : html` - + `, + ], + [ + CollectionAccess.Unlisted, + () => html` + + + + `, + ], + [ + CollectionAccess.Public, + () => html` + - `} + `, + ], + ])} void this.onTogglePublic(col, true)} > - - ${msg("Make Shareable")} + + ${msg("Enable Share Link")}
` : html` - - - - Visit Shareable URL - + { + ClipboardController.copyToClipboard(this.getShareLink(col)); + this.notify.toast({ + message: msg("Link copied"), + }); + }} + > + + ${msg("Copy Share Link")} void this.onTogglePublic(col, false)} > - + ${msg("Make Private")} `} diff --git a/frontend/src/pages/org/profile.ts b/frontend/src/pages/org/profile.ts index 51e9fa326f..94c4104c22 100644 --- a/frontend/src/pages/org/profile.ts +++ b/frontend/src/pages/org/profile.ts @@ -2,22 +2,11 @@ import { localized, msg, str } from "@lit/localize"; import { Task } from "@lit/task"; import { html, nothing } 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 { BtrixElement } from "@/classes/BtrixElement"; -import { pageTitle } from "@/layouts/pageHeader"; -import type { OrgData } from "@/types/org"; - -type OrgProfileData = { - org: { - name: string; - description: string; - url: string; - verified: boolean; - }; - collections: unknown[]; -}; +import { page } from "@/layouts/page"; +import type { OrgData, OrgProfileData } from "@/types/org"; type PublicCollection = { name: string; @@ -38,7 +27,6 @@ export class OrgProfile extends BtrixElement { private isPrivatePreview = false; readonly publicOrg = new Task(this, { - autoRun: false, task: async ([slug]) => { if (!slug) return; const org = await this.fetchOrgProfile(slug); @@ -47,10 +35,6 @@ export class OrgProfile extends BtrixElement { args: () => [this.slug] as const, }); - protected firstUpdated(): void { - void this.publicOrg.run(); - } - render() { if (!this.slug) { return this.renderError(); @@ -109,94 +93,94 @@ export class OrgProfile extends BtrixElement { private renderProfile({ org }: OrgProfileData) { return html` - - ${this.isPrivatePreview ? this.renderPreviewBanner() : nothing} - -
- -
-
- ${pageTitle(org.name)} - ${org.verified && - html``} + ${page( + { + title: org.name, + suffix: org.verified + ? html`` + : nothing, + actions: when( + this.appState.isAdmin, + () => + html`
+ + + +
`, + ), + secondary: html` ${when( - this.appState.isAdmin, - () => - html`
- - - -
`, + org.description, + (description) => html` +
${description}
+ `, )} -
- ${when( - org.description, - (description) => html` -
${description}
- `, - )} - ${when(org.url, (urlStr) => { - let url: URL; - try { - url = new URL(urlStr); - } catch { - return nothing; - } + ${when(org.url, (urlStr) => { + let url: URL; + try { + url = new URL(urlStr); + } catch { + return nothing; + } - return html` - - `; - })} -
+ + + ${url.href.split("//")[1].replace(/\/$/, "")} + +
+ `; + })} + `, + }, + () => this.renderCollections(), + )} + `; + } -
-

${msg("Collections")}

- ${when( - this.appState.isAdmin, - () => - html` - - `, - )} -
+ private renderCollections() { + return html` +
+

${msg("Collections")}

+ ${when( + this.appState.isAdmin, + () => + html` + + `, + )} +
-
- ${this.renderCollections(this.collections)} -
+
+ ${this.renderCollectionsList(this.collections)}
`; } - private renderCollections(collections: PublicCollection[]) { + private renderCollectionsList(collections: PublicCollection[]) { if (!collections.length) { return html`

diff --git a/frontend/src/pages/org/workflow-detail.ts b/frontend/src/pages/org/workflow-detail.ts index b46fdb1965..ff9f83549f 100644 --- a/frontend/src/pages/org/workflow-detail.ts +++ b/frontend/src/pages/org/workflow-detail.ts @@ -11,9 +11,9 @@ import queryString from "query-string"; import type { Crawl, Seed, Workflow, WorkflowParams } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; -import { CopyButton } from "@/components/ui/copy-button"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { type IntersectEvent } from "@/components/utils/observable"; +import { ClipboardController } from "@/controllers/clipboard"; import type { CrawlLog } from "@/features/archived-items/crawl-logs"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { ExclusionEditor } from "@/features/crawl-workflows/exclusion-editor"; @@ -719,7 +719,8 @@ export class WorkflowDetail extends BtrixElement { ${msg("Edit Workflow Settings")} CopyButton.copyToClipboard(workflow.tags.join(", "))} + @click=${() => + ClipboardController.copyToClipboard(workflow.tags.join(", "))} ?disabled=${!workflow.tags.length} > diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 33e5a5fff2..b315027160 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -15,9 +15,9 @@ import { } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; -import { CopyButton } from "@/components/ui/copy-button"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { type SelectEvent } from "@/components/ui/search-combobox"; +import { ClipboardController } from "@/controllers/clipboard"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { pageHeader } from "@/layouts/pageHeader"; import scopeTypeLabels from "@/strings/crawl-workflows/scopeType"; @@ -576,7 +576,8 @@ export class WorkflowsList extends BtrixElement { `, )} CopyButton.copyToClipboard(workflow.tags.join(", "))} + @click=${() => + ClipboardController.copyToClipboard(workflow.tags.join(", "))} ?disabled=${!workflow.tags.length} > diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 35b95497eb..598de394aa 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -36,6 +36,7 @@ export const ROUTES = { ].join(""), publicOrgs: `/${RouteNamespace.PublicOrgs}(/)`, publicOrgProfile: `/${RouteNamespace.PublicOrgs}/:slug(/)`, + publicCollection: `/${RouteNamespace.PublicOrgs}/:slug/collections/:collectionId(/:collectionTab)`, users: "/users", usersInvite: "/users/invite", crawls: "/crawls", diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 2acfbacd8a..1a6131e8d1 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -376,6 +376,18 @@ sl-drawer::part(footer) { border-top: 1px solid var(--sl-panel-border-color); } + + sl-button.button-card { + @apply w-full; + } + + sl-button.button-card::part(base) { + @apply min-h-20 justify-between leading-none; + } + + sl-button.button-card::part(label) { + @apply flex flex-1 flex-col justify-center gap-2 text-left; + } } /* Following styles won't work with layers */ diff --git a/frontend/src/types/events.d.ts b/frontend/src/types/events.d.ts index 5e41d6f498..7b08caaa2d 100644 --- a/frontend/src/types/events.d.ts +++ b/frontend/src/types/events.d.ts @@ -1,4 +1,5 @@ import { type APIEventMap } from "@/controllers/api"; +import { type CopiedEventMap } from "@/controllers/clipboard"; import { type NavigateEventMap } from "@/controllers/navigate"; import { type NotifyEventMap } from "@/controllers/notify"; import { type UserGuideEventMap } from "@/index"; @@ -14,5 +15,6 @@ declare global { NotifyEventMap, AuthEventMap, APIEventMap, - UserGuideEventMap {} + UserGuideEventMap, + CopiedEventMap {} } diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index 8eb2770bea..f7c4d15a0e 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -101,3 +101,25 @@ export type OrgData = z.infer; export const orgConfigSchema = z.unknown(); export type OrgConfig = z.infer; + +export const publicCollectionSchema = z.object({ + id: z.string(), + oid: z.string(), + name: z.string(), + description: z.string(), + crawlCount: z.number(), + pageCount: z.number(), + totalSize: z.number(), +}); +export type PublicCollection = z.infer; + +export const orgProfileDataSchema = z.object({ + org: z.object({ + name: z.string(), + description: z.string(), + url: z.string(), + verified: z.boolean(), + }), + collections: z.array(publicCollectionSchema), +}); +export type OrgProfileData = z.infer; diff --git a/frontend/xliff/es.xlf b/frontend/xliff/es.xlf index 08354f94cb..d18060703b 100644 --- a/frontend/xliff/es.xlf +++ b/frontend/xliff/es.xlf @@ -41,9 +41,6 @@ Archived Items - - Shareable - Private @@ -69,33 +66,15 @@ Share Collection - - Make this collection shareable to enable a public viewing link. - - - Collection is Shareable - Done Link to Share - - This collection can be viewed by anyone with the link. - Open in New Tab - - Embed Collection - - - Share this collection by embedding it into an existing webpage. - - - Add the following embed code to your HTML page: - Copy Embed Code @@ -124,9 +103,6 @@ Select Archived Items - - Make Shareable - Make Private @@ -710,12 +686,6 @@ My Collection - - Publicly Accessible - - - Enable public access to make Collections shareable. Only people with the shared link can view your Collection. - Successfully saved "" Collection. @@ -746,9 +716,6 @@ Specify a domain name, start page URL, or path on a website and let the crawler automatically find pages within that scope. - - Copied to clipboard! - Copy @@ -1701,9 +1668,6 @@ Shareable Collection - - Private Collection - Something unexpected went wrong while retrieving Collections. @@ -3805,6 +3769,9 @@ Pages currently being crawled will be completed and saved, and finished pages will be kept, but all remaining pages in the queue will be discarded. Are you sure you want to stop crawling? + + Copied + Did you click a link to get here? @@ -3820,9 +3787,61 @@ This organization has been verified by Webrecorder to be who they say they are. + + Only org members can view + + + Unlisted + + + Only people with the link can view + + + Public + + + Anyone can view on the org's public profile + Visibility + + Copy unlisted link + + + Copy public link + + + Copy Link + + + View Embed Code + + + Visit Unlisted Page + + + Visit Public Page + + + Change Link Visibility + + + Share “” + + + Embed Code + + + Change the visibility setting to embed this collection. + + + To embed this collection into an existing webpage, add the + following embed code: + + + Updated collection visibility. + Allow anyone to view org @@ -3872,6 +3891,15 @@ Use this ID to reference your org in the Browsertrix API. + + Enable Share Link + + + Link copied + + + Copy Share Link + This is a private preview of your org's profile page @@ -3911,6 +3939,18 @@ Request Password Reset + + About + + + Collection by + + + A description has not been provided for this collection. + + + Date Range + Sign In diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0b19302970..573dc98745 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1465,10 +1465,10 @@ resolved "https://registry.yarnpkg.com/@shoelace-style/localize/-/localize-3.2.1.tgz#9aa0078bef68a357070b104df95c75701c962c79" integrity sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA== -"@shoelace-style/shoelace@~2.15.1": - version "2.15.1" - resolved "https://registry.yarnpkg.com/@shoelace-style/shoelace/-/shoelace-2.15.1.tgz#2fa6bd8e493801f5b5b4744fab0fa108bbc01934" - integrity sha512-3ecUw8gRwOtcZQ8kWWkjk4FTfObYQ/XIl3aRhxprESoOYV1cYhloYPsmQY38UoL3+pwJiZb5+LzX0l3u3Zl0GA== +"@shoelace-style/shoelace@~2.18.0": + version "2.18.0" + resolved "https://registry.yarnpkg.com/@shoelace-style/shoelace/-/shoelace-2.18.0.tgz#21435ad39c4759210c0b4dab342c548ec989fba7" + integrity sha512-uzpL0+8Qm8aE2ArcXBcKHkaPc6l7ymuVaN6xJM0yd2o3talcoXpuP+gRBsgggSZKuuJEa+JkEuLDdzzFnE/+jw== dependencies: "@ctrl/tinycolor" "^4.0.2" "@floating-ui/dom" "^1.5.3"