@@ -323,6 +339,14 @@ export class WebstatusOverviewContent extends LitElement {
.userContext=${this.userContext!}
.savedSearch=${userSavedSearch?.value}
.location=${this.location}
- >`;
+ >
+
+ this._updatePageUrl('', this.location, {subscribe: false})}
+ >`;
}
}
diff --git a/frontend/src/static/js/components/webstatus-overview-data-loader.ts b/frontend/src/static/js/components/webstatus-overview-data-loader.ts
index 035a3e402..9076e6c41 100644
--- a/frontend/src/static/js/components/webstatus-overview-data-loader.ts
+++ b/frontend/src/static/js/components/webstatus-overview-data-loader.ts
@@ -54,11 +54,9 @@ export class WebstatusOverviewDataLoader extends LitElement {
savedSearch: CurrentSavedSearch;
render(): TemplateResult {
- const columns: ColumnKey[] = parseColumnsSpec(
- getColumnsSpec(this.location),
- );
const location = this.location;
if (!location) return html``;
+ const columns: ColumnKey[] = parseColumnsSpec(getColumnsSpec(location));
const sortSpec = getSortSpec(location) || DEFAULT_SORT_SPEC;
const groupCells = renderGroupCells(location, columns, sortSpec!);
let headerCells: TemplateResult[] = [];
diff --git a/frontend/src/static/js/components/webstatus-overview-pagination.ts b/frontend/src/static/js/components/webstatus-overview-pagination.ts
index 3b75f58d7..6515c1a78 100644
--- a/frontend/src/static/js/components/webstatus-overview-pagination.ts
+++ b/frontend/src/static/js/components/webstatus-overview-pagination.ts
@@ -208,8 +208,10 @@ export class WebstatusOverviewPagination extends LitElement {
return html``;
}
- this.start = getPaginationStart(this.location);
- this.pageSize = getPageSize(this.location);
+ this.start = this.location ? getPaginationStart(this.location) : 0;
+ this.pageSize = this.location
+ ? getPageSize(this.location)
+ : DEFAULT_ITEMS_PER_PAGE;
const prevUrl = this.formatUrlForRelativeOffset(-this.pageSize);
const nextUrl = this.formatUrlForRelativeOffset(this.pageSize);
diff --git a/frontend/src/static/js/components/webstatus-saved-search-controls.ts b/frontend/src/static/js/components/webstatus-saved-search-controls.ts
index c17f968f4..856349461 100644
--- a/frontend/src/static/js/components/webstatus-saved-search-controls.ts
+++ b/frontend/src/static/js/components/webstatus-saved-search-controls.ts
@@ -16,6 +16,7 @@
import {LitElement, TemplateResult, css, html, nothing} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
+import {openShareDialog} from './webstatus-saved-search-share-dialog.js';
import {APIClient} from '../contexts/api-client-context.js';
import {UserContext} from '../contexts/firebase-user-context.js';
import {
@@ -207,24 +208,15 @@ export class WebstatusSavedSearchControls extends LitElement {
savedSearch: UserSavedSearch,
): TemplateResult {
const isOwner = savedSearch.permissions?.role === BookmarkOwnerRole;
- const shareableUrl = `${this._getOrigin()}${this._formatOverviewPageUrl(this.location, {q: `saved:${savedSearch.id}`})}`;
return html`
-
-
+
{
+ const shareableUrl = `${this._getOrigin()}${this._formatOverviewPageUrl(this.location, {q: `saved:${savedSearch.id}`})}`;
+ void openShareDialog(savedSearch, shareableUrl);
+ }}
+ >
${this.renderBookmarkControl(savedSearch, isOwner)}
${isOwner
? html`
diff --git a/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts b/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts
new file mode 100644
index 000000000..8e02c6873
--- /dev/null
+++ b/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts
@@ -0,0 +1,239 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LitElement, TemplateResult, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {UserSavedSearch} from '../utils/constants.js';
+import {Toast} from '../utils/toast.js';
+import {SHARED_STYLES} from '../css/shared-css.js';
+
+@customElement('webstatus-saved-search-share-dialog')
+export class WebstatusSavedSearchShareDialog extends LitElement {
+ @property({type: Object})
+ savedSearch?: UserSavedSearch;
+
+ @property({type: String})
+ shareableUrl: string = '';
+
+ get effectiveUrl(): string {
+ try {
+ const url = new URL(this.shareableUrl);
+ url.searchParams.set('subscribe', 'true');
+ return url.toString();
+ } catch {
+ // Fallback if shareableUrl is relative or invalid.
+ const separator = this.shareableUrl.includes('?') ? '&' : '?';
+ return `${this.shareableUrl}${separator}subscribe=true`;
+ }
+ }
+
+ static styles = [
+ SHARED_STYLES,
+ css`
+ sl-dialog::part(body) {
+ padding-top: 0;
+ }
+
+ .qr-code-box {
+ border: 1px solid var(--border-color, #ccc);
+ border-radius: var(--border-radius, 4px);
+ padding: var(--content-padding-half) var(--content-padding)
+ var(--content-padding-quarter) var(--content-padding);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--content-padding);
+ margin: 0 auto;
+ margin-bottom: var(--content-padding-quarter);
+ width: 25em;
+ }
+
+ .qr-code-box h3 {
+ margin: 0.5em;
+ font-weight: bold;
+ font-size: 1rem;
+ }
+
+ .qr-code-box sl-button::part(base) {
+ padding-bottom: var(--content-padding-quarter);
+ font-weight: normal;
+ }
+
+ .link-share-box {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ gap: 0;
+ width: 100%;
+ border-radius: var(--border-radius);
+ align-items: stretch;
+ margin-top: 1em;
+ }
+
+ .link-share-box h3 {
+ grid-column: 1;
+ grid-row: 1;
+ margin: 0;
+ font-size: 1rem;
+ font-weight: bold;
+ }
+
+ .link-share-box sl-input {
+ grid-column: 1;
+ grid-row: 2;
+ }
+
+ .link-share-box sl-input::part(base) {
+ border: none;
+ background-color: transparent;
+ box-shadow: none;
+ }
+
+ .link-share-box sl-input::part(input) {
+ padding-left: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ font-size: 0.875rem;
+ }
+
+ .link-share-box sl-button {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ align-self: stretch;
+ }
+ `,
+ ];
+
+ async openWithContext(savedSearch: UserSavedSearch, shareableUrl: string) {
+ this.savedSearch = savedSearch;
+ this.shareableUrl = shareableUrl;
+ const dialog = this.shadowRoot?.querySelector('sl-dialog');
+ if (dialog?.show) await dialog.show();
+ }
+
+ async hide() {
+ const dialog = this.shadowRoot?.querySelector('sl-dialog');
+ if (dialog?.hide) await dialog.hide();
+ }
+
+ async drawLogoOnCanvas(qrCodeElement: Element) {
+ if (!qrCodeElement.shadowRoot) return;
+ const canvas = qrCodeElement.shadowRoot.querySelector('canvas');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const logo = new Image();
+ logo.src = '/public/img/cross.svg';
+ await logo.decode();
+
+ const logoSize = canvas.width * 0.2;
+ const x = (canvas.width - logoSize) / 2;
+ const y = (canvas.height - logoSize) / 2;
+
+ ctx.drawImage(logo, x, y, logoSize, logoSize);
+ }
+
+ saveQRCode() {
+ const canvas = this.shadowRoot
+ ?.querySelector('sl-qr-code')
+ ?.shadowRoot?.querySelector('canvas');
+ if (!canvas) return;
+ const dataURL = canvas.toDataURL('image/png');
+ const a = document.createElement('a');
+ a.href = dataURL;
+ a.download = `qr-code-${this.savedSearch?.id}.png`;
+ a.click();
+ }
+
+ async copyToClipboard() {
+ try {
+ await navigator.clipboard.writeText(this.effectiveUrl);
+ await new Toast().toast(
+ 'Link copied to clipboard',
+ 'success',
+ 'info-circle',
+ );
+ } catch (err) {
+ console.error('Failed to copy: ', err);
+ await new Toast().toast(
+ 'Failed to copy link',
+ 'danger',
+ 'exclamation-triangle',
+ );
+ }
+ }
+
+ render(): TemplateResult {
+ return html`
+
+
+
+
Share via QR code
+ {
+ if (e.target instanceof Element) {
+ void this.drawLogoOnCanvas(e.target);
+ }
+ }}
+ >
+
+ Save QR code
+
+
+
+
+
Share via link
+
+
+ Copy link
+
+
+
+
+ `;
+ }
+}
+
+let shareDialogEl: WebstatusSavedSearchShareDialog | null = null;
+
+export async function openShareDialog(
+ savedSearch: UserSavedSearch,
+ shareableUrl: string,
+): Promise
{
+ if (!shareDialogEl) {
+ shareDialogEl = new WebstatusSavedSearchShareDialog();
+ document.body.appendChild(shareDialogEl);
+ await shareDialogEl.updateComplete;
+ }
+ await shareDialogEl.openWithContext(savedSearch, shareableUrl);
+ return shareDialogEl;
+}
diff --git a/frontend/src/static/js/components/webstatus-subscribe-button.ts b/frontend/src/static/js/components/webstatus-subscribe-button.ts
index a80b00c90..7e0286746 100644
--- a/frontend/src/static/js/components/webstatus-subscribe-button.ts
+++ b/frontend/src/static/js/components/webstatus-subscribe-button.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import {LitElement, html, TemplateResult} from 'lit';
+import {LitElement, html, TemplateResult, PropertyValueMap} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {consume} from '@lit/context';
import {
@@ -45,9 +45,24 @@ export class SubscribeButton extends LitElement {
@property({attribute: false})
toaster = toast;
+ @property({type: Boolean})
+ autoOpen = false;
+
@state()
private _isSubscriptionDialogOpen = false;
+ protected updated(changedProperties: PropertyValueMap): void {
+ super.updated(changedProperties);
+ if (
+ (changedProperties.has('autoOpen') ||
+ changedProperties.has('userContext')) &&
+ this.autoOpen &&
+ this.userContext
+ ) {
+ this._isSubscriptionDialogOpen = true;
+ }
+ }
+
render(): TemplateResult {
if (!this.userContext || !this.savedSearchId) {
return html``;
diff --git a/frontend/src/static/js/index.ts b/frontend/src/static/js/index.ts
index 6ec2910f5..ecd329396 100644
--- a/frontend/src/static/js/index.ts
+++ b/frontend/src/static/js/index.ts
@@ -29,6 +29,7 @@ import '@shoelace-style/shoelace/dist/components/input/input.js';
import '@shoelace-style/shoelace/dist/components/menu/menu.js';
import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js';
import '@shoelace-style/shoelace/dist/components/option/option.js';
+import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js';
import '@shoelace-style/shoelace/dist/components/radio-button/radio-button.js';
import '@shoelace-style/shoelace/dist/components/radio-group/radio-group.js';
import '@shoelace-style/shoelace/dist/components/select/select.js';
diff --git a/frontend/src/static/js/utils/urls.ts b/frontend/src/static/js/utils/urls.ts
index 1429be07b..e9cf09c67 100644
--- a/frontend/src/static/js/utils/urls.ts
+++ b/frontend/src/static/js/utils/urls.ts
@@ -51,6 +51,10 @@ export function getEditSavedSearch(location: {search: string}): boolean {
return Boolean(getQueryParam(location.search, 'edit_saved_search'));
}
+export function getSubscribeToSavedSearch(location: {search: string}): boolean {
+ return Boolean(getQueryParam(location.search, 'subscribe'));
+}
+
export interface DateRange {
start?: Date;
end?: Date;
@@ -85,6 +89,7 @@ export type QueryStringOverrides = {
dateRange?: DateRange;
column_options?: string[];
edit_saved_search?: boolean;
+ subscribe?: boolean;
};
/* Given the router location object, return a query string with
@@ -161,6 +166,11 @@ function getContextualQueryStringParams(
searchParams.set('edit_saved_search', '' + editBookmark);
}
+ const subscribe = 'subscribe' in overrides ? overrides.subscribe : undefined;
+ if (subscribe) {
+ searchParams.set('subscribe', '' + subscribe);
+ }
+
return searchParams.toString() ? '?' + searchParams.toString() : '';
}