Skip to content

Commit

Permalink
feat: Public org profile page (#2172)
Browse files Browse the repository at this point in the history
- Enables creating a public org profile page with description and
website at `/profile/<org slug>`
- Updates current "Overview" page to be "Dashboard", found under
`/dashboard`
- Organizes org "General" settings tab by "General", "Profile", and
"Developer Tools"
- Adds sign up banner to log in page for consistent CTA banners
- Updates copy and docs to support changes
- Allows user to set collection to private, public, or unlisted
- Adds route for public collection page with basic page layout
- Refactors copy button to abstract clipboard functionality
---------

Co-authored-by: Henry Wilkinson <[email protected]>
Co-authored-by: emma <[email protected]>
  • Loading branch information
3 people committed Dec 16, 2024
1 parent 50d0a36 commit 5e7bc26
Show file tree
Hide file tree
Showing 32 changed files with 1,038 additions and 198 deletions.
4 changes: 2 additions & 2 deletions frontend/docs/docs/user-guide/crawl-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ You can create, view, search for, and run crawl workflows from the **Crawling**

## Create a Crawl Workflow

Create new crawl workflows from the **Crawling** page, or the _Create New ..._ shortcut from **Overview**.
Create new crawl workflows from the **Crawling** page, or the _Create New ..._ shortcut from **Dashboard**.

### Choose what to crawl

Expand Down Expand Up @@ -38,4 +38,4 @@ Re-running a crawl workflow can be useful to capture a website as it changes ove

## Status

Finished crawl workflows inherit the [status of the last archived item they created](archived-items.md#status). Crawl workflows that are in progress maintain their [own statuses](./running-crawl.md#crawl-workflow-status).
Finished crawl workflows inherit the [status of the last archived item they created](archived-items.md#status). Crawl workflows that are in progress maintain their [own statuses](./running-crawl.md#crawl-workflow-status).
2 changes: 1 addition & 1 deletion frontend/docs/docs/user-guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ To start crawling with hosted Browsertrix, you'll need a Browsertrix account. [S

## Starting the crawl

Once you've logged in you should see your org [overview](overview.md). If you land somewhere else, navigate to **Overview**.
Once you've logged in you should see your org [overview](overview.md). If you land somewhere else, navigate to **Dashboard**.

1. Tap the _Create New..._ shortcut and select **Crawl Workflow**.
2. Choose **Page List**. We'll get into the details of the options [later](./crawl-workflows.md), but this is a good starting point for a simple crawl.
Expand Down
12 changes: 11 additions & 1 deletion frontend/docs/docs/user-guide/org-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ Settings that apply to the entire organization are found in the **Settings** pag

## General

Change your organization's name and URL identifier in this tab. Choose an org name that's unique and memorable, like the name of your company or organization. Org name and URLs are unique to each Browsertrix instance (for example, on browsertrix.com) and you may be asked to change the org name or URL identifier if either are already in use by another org.
### Name and URL

Choose a display name for your org that's unique and memorable, like the name of your company, organization, or personal project. This name will be visible in the org's [public profile](#profile), if that page is enabled.

The org URL is where you and other org members will go to view the dashboard, configure org settings, and manage all other org-related activities. Changing this URL will also update the URL of your org's public profile, if enabled.

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 identifier if either are already in use by another org.

### Profile

Enable and configure a public profile page for your org. Once enabled, anyone on the internet with a link to your org's profile page will be able to view public information like the org name, description, and public collections.

## Billing

Expand Down
2 changes: 1 addition & 1 deletion frontend/docs/docs/user-guide/overview.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# View Usage Stats and Quotas

The **Overview** dashboard delivers key statistics about the org's resource usage. You can also create crawl workflows, upload archived items, create collections, and create browser profiles through the _Create New ..._ shortcut.
Your **Dashboard** delivers key statistics about the org's resource usage. You can also create crawl workflows, upload archived items, create collections, and create browser profiles through the _Create New ..._ shortcut.

## Storage

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ import("./not-found");
import("./screencast");
import("./beta-badges");
import("./detail-page-title");
import("./verified-badge");
35 changes: 27 additions & 8 deletions frontend/src/components/not-found.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import { localized, msg } from "@lit/localize";
import { html, LitElement } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators.js";

@customElement("btrix-not-found")
import { BtrixElement } from "@/classes/BtrixElement";

@localized()
export class NotFound extends LitElement {
createRenderRoot() {
return this;
}
@customElement("btrix-not-found")
export class NotFound extends BtrixElement {
render() {
return html`
<div class="text-center text-xl text-gray-400">
${msg("Page not found")}
<div class="text-center text-neutral-500">
<p class="my-4 border-b py-4 text-xl leading-none text-neutral-400">
${msg("Page not found")}
</p>
<p>
${msg("Did you click a link to get here?")}
<button
class="text-blue-500 transition-colors hover:text-blue-600"
@click=${() => {
window.history.back();
}}
>
${msg("Go Back")}
</button>
<br />
${msg("Or")}
<btrix-link
href="https://github.com/webrecorder/browsertrix/issues/new/choose"
>
${msg("Report a Broken Link")}
</btrix-link>
</p>
</div>
`;
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import "./card";
import "./data-table";
import "./desc-list";
import "./dialog";
import "./link";
import "./navigation";
import "./tab-group";
import "./tab-list";
import "./url-input";

import("./code");
import("./combobox");
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/ui/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import clsx from "clsx";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";

import { BtrixElement } from "@/classes/BtrixElement";

@customElement("btrix-link")
export class Link extends BtrixElement {
@property({ type: String })
href?: HTMLAnchorElement["href"];

@property({ type: String })
target?: HTMLAnchorElement["target"];

@property({ type: String })
rel?: HTMLAnchorElement["rel"];

@property({ type: String })
variant: "primary" | "neutral" = "neutral";

render() {
if (!this.href) return;

return html`
<a
class=${clsx(
"group inline-flex items-center gap-1 transition-colors",
{
primary: "text-primary-500 hover:text-primary-600",
neutral: "text-blue-500 hover:text-blue-600",
}[this.variant],
)}
href=${this.href}
target=${ifDefined(this.target)}
rel=${ifDefined(this.rel)}
@click=${this.target === "_blank" || this.href.startsWith("http")
? () => {}
: this.navigate.link}
>
<slot></slot>
<sl-icon
slot="suffix"
name="arrow-right"
class="size-4 transition-transform group-hover:translate-x-1"
></sl-icon
></a>
`;
}
}
75 changes: 75 additions & 0 deletions frontend/src/components/ui/url-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// import type { SlInputEvent } from "@shoelace-style/shoelace";
import { msg } from "@lit/localize";
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js";
import { customElement, property } from "lit/decorators.js";

export function validURL(url: string) {
// adapted from: https://gist.github.com/dperini/729294
return /^(?:https?:\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(
url,
);
}

/**
* URL input field with validation.
*
* @TODO Use types from SlInput
*
* @attr {String} name
* @attr {String} size
* @attr {String} name
* @attr {String} label
* @attr {String} value
*/
@customElement("btrix-url-input")
export class Component extends SlInput {
@property({ type: Number, reflect: true })
minlength = 4;

@property({ type: String, reflect: true })
placeholder = "https://example.com";

connectedCallback(): void {
this.inputmode = "url";

super.connectedCallback();

this.addEventListener("sl-input", this.onInput);
this.addEventListener("sl-blur", this.onBlur);
}

disconnectedCallback(): void {
super.disconnectedCallback();

this.removeEventListener("sl-input", this.onInput);
this.removeEventListener("sl-blur", this.onBlur);
}

private readonly onInput = async () => {
console.log("input 1");
await this.updateComplete;

if (!this.checkValidity() && validURL(this.value)) {
this.setCustomValidity("");
this.helpText = "";
}
};

private readonly onBlur = async () => {
await this.updateComplete;

const value = this.value;

if (value && !validURL(value)) {
const text = msg("Please enter a valid URL.");
this.helpText = text;
this.setCustomValidity(text);
} else if (
value &&
!value.startsWith("https://") &&
!value.startsWith("http://")
) {
this.value = `https://${value}`;
}
};
}
26 changes: 26 additions & 0 deletions frontend/src/components/verified-badge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { localized, msg } from "@lit/localize";
import { html } from "lit";
import { customElement } from "lit/decorators.js";

import { BtrixElement } from "@/classes/BtrixElement";

@localized()
@customElement("btrix-verified-badge")
export class Component extends BtrixElement {
render() {
return html`
<sl-tooltip
class="part-[body]:max-w-48 part-[body]:text-xs"
content=${msg(
"This organization has been verified by Webrecorder to be who they say they are.",
)}
>
<btrix-tag
><sl-icon name="check-circle-fill" class="-ml-1 mr-1"></sl-icon>${msg(
"Verified",
)}</btrix-tag
>
</sl-tooltip>
`;
}
}
3 changes: 2 additions & 1 deletion frontend/src/controllers/navigate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";

import { RouteNamespace } from "@/routes";
import appState from "@/utils/state";

export type NavigateEventDetail = {
Expand Down Expand Up @@ -31,7 +32,7 @@ export class NavigateController implements ReactiveController {
get orgBasePath() {
const slug = appState.orgSlug;
if (slug) {
return `/orgs/${slug}`;
return `/${RouteNamespace.PrivateOrgs}/${slug}`;
}
return "/";
}
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/features/crawl-workflows/workflow-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type { SelectCrawlerProxyChangeEvent } from "@/components/ui/select-crawl
import type { Tab } from "@/components/ui/tab-list";
import type { TagInputEvent, TagsChangeEvent } from "@/components/ui/tag-input";
import type { TimeInputChangeEvent } from "@/components/ui/time-input";
import { validURL } from "@/components/ui/url-input";
import { type SelectBrowserProfileChangeEvent } from "@/features/browser-profiles/select-browser-profile";
import type { CollectionsChangeEvent } from "@/features/collections/collections-add";
import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table";
Expand Down Expand Up @@ -161,13 +162,6 @@ function getLocalizedWeekDays() {
);
}

function validURL(url: string) {
// adapted from: https://gist.github.com/dperini/729294
return /^(?:https?:\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(
url,
);
}

const trimArray = flow(uniq, compact);
const urlListToArray = flow(
(str?: string) => (str?.length ? str.trim().split(/\s+/g) : []),
Expand Down Expand Up @@ -786,6 +780,7 @@ export class WorkflowEditor extends BtrixElement {
${this.formState.scopeType === ScopeType.Page
? html`
${inputCol(html`
<!-- TODO Use btrix-url-input -->
<sl-input
name="urlList"
label=${msg("Page URL")}
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe("browsertrix-app", () => {
AppStateService.resetAll();
AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey);
window.sessionStorage.clear();
window.localStorage.clear();
stub(window.history, "pushState");
stub(NotifyController.prototype, "toast");
});
Expand Down Expand Up @@ -155,21 +156,21 @@ describe("browsertrix-app", () => {
expect(el.appState.orgSlug).to.equal("test-org");
});

it("sets org slug from path", async () => {
it("sets org slug from path if user is in org", async () => {
const id = self.crypto.randomUUID();
const mockOrg = {
id: id,
name: "test org 2",
slug: id,
role: 10,
};
stub(App.prototype, "getLocationPathname").callsFake(() => `/orgs/${id}`);
stub(App.prototype, "getUserInfo").callsFake(async () =>
Promise.resolve({
AppStateService.updateUser(
formatAPIUser({
...mockAPIUser,
orgs: [...mockAPIUser.orgs, mockOrg],
}),
);
stub(App.prototype, "getLocationPathname").callsFake(() => `/orgs/${id}`);
stub(AuthService.prototype, "startFreshnessCheck").callsFake(() => {});
stub(AuthService, "initSessionStorage").callsFake(async () =>
Promise.resolve({
Expand Down
Loading

0 comments on commit 5e7bc26

Please sign in to comment.