Support for Public / Shareable Collections (#1038)

* collections: support toggling collections public/private, viewable via RWP
- backend: add 'public' to collection model, support patching to update
- backend: add .../collections/<id>/public/replay.json for public access
- backend: add CORS handling for public endpoint
- frontend: support 'make shareable / make private' dropdown actions on collection detail + collection list views
- frontend: show shareable / private icons by collection name on detail + list views
- frontend: link to replayweb.page for standalone browsing
- frontend: add embed code popup when a collection is shareable
- refer to public collections as 'shareable' for now

---------
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
This commit is contained in:
Ilya Kreymer 2023-08-03 19:11:01 -07:00 committed by GitHub
parent 62d3399223
commit 362afa47bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 294 additions and 15 deletions

View File

@ -7,7 +7,7 @@ import uuid
from typing import Optional, List
import pymongo
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from .basecrawls import SUCCESSFUL_STATES
@ -148,10 +148,14 @@ class CollectionOps:
return await self.get_collection(coll_id, org)
async def get_collection(
self, coll_id: uuid.UUID, org: Organization, resources=False
self, coll_id: uuid.UUID, org: Organization, resources=False, public_only=False
):
"""Get collection by id"""
result = await self.collections.find_one({"_id": coll_id})
query = {"_id": coll_id}
if public_only:
query["isPublic"] = True
result = await self.collections.find_one(query)
if resources:
result["resources"] = await self.get_collection_crawl_resources(
coll_id, org
@ -348,6 +352,7 @@ def init_collections_api(app, mdb, crawls, orgs, crawl_manager):
org_crawl_dep = orgs.org_crawl_dep
org_viewer_dep = orgs.org_viewer_dep
org_public = orgs.org_public
@app.post("/orgs/{oid}/collections", tags=["collections"])
async def add_collection(
@ -428,8 +433,36 @@ def init_collections_api(app, mdb, crawls, orgs, crawl_manager):
coll = await colls.get_collection(coll_id, org, resources=True)
if not coll:
raise HTTPException(status_code=404, detail="collection_not_found")
return coll
@app.get(
"/orgs/{oid}/collections/{coll_id}/public/replay.json", tags=["collections"]
)
async def get_collection_public_replay(
response: Response,
coll_id: uuid.UUID,
org: Organization = Depends(org_public),
):
coll = await colls.get_collection(
coll_id, org, resources=True, public_only=True
)
if not coll:
raise HTTPException(status_code=404, detail="collection_not_found")
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "*"
return coll
@app.options(
"/orgs/{oid}/collections/{coll_id}/public/replay.json", tags=["collections"]
)
async def get_replay_preflight(response: Response):
response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "*"
return {}
@app.patch("/orgs/{oid}/collections/{coll_id}", tags=["collections"])
async def update_collection(
coll_id: uuid.UUID,

View File

@ -465,6 +465,8 @@ class Collection(BaseMongoModel):
# Sorted by count, descending
tags: Optional[List[str]] = []
isPublic: Optional[bool] = False
# ============================================================================
class CollIn(BaseModel):
@ -474,6 +476,8 @@ class CollIn(BaseModel):
description: Optional[str]
crawlIds: Optional[List[str]] = []
isPublic: Optional[bool] = False
# ============================================================================
class CollOut(Collection):
@ -488,6 +492,7 @@ class UpdateColl(BaseModel):
name: Optional[str]
description: Optional[str]
isPublic: Optional[bool]
# ============================================================================

View File

@ -45,6 +45,7 @@ class OrgOps:
self.org_viewer_dep = None
self.org_crawl_dep = None
self.org_owner_dep = None
self.org_public = None
self.invites = invites
@ -300,6 +301,13 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User):
return org
async def org_public(oid: str):
org = await ops.get_org_by_id(uuid.UUID(oid))
if not org:
raise HTTPException(status_code=404, detail="org_not_found")
return org
router = APIRouter(
prefix="/orgs/{oid}",
dependencies=[Depends(org_dep)],
@ -310,6 +318,7 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User):
ops.org_viewer_dep = org_dep
ops.org_crawl_dep = org_crawl_dep
ops.org_owner_dep = org_owner_dep
ops.org_public = org_public
@app.get("/orgs", tags=["organizations"], response_model=PaginatedResponse)
async def get_orgs(

View File

@ -402,7 +402,6 @@ def init_users_api(app, user_manager):
}
for org in user_orgs
]
print(f"user info with orgs: {user_info}", flush=True)
return user_info
@users_router.get("/invite/{token}", tags=["invites"])

View File

@ -51,6 +51,9 @@ export class CollectionDetail extends LiteElement {
@state()
private isDescriptionExpanded = false;
@state()
private showEmbedInfo = false;
// Use to cancel requests
private getArchivedItemsController: AbortController | null = null;
@ -89,12 +92,32 @@ export class CollectionDetail extends LiteElement {
render() {
return html`${this.renderHeader()}
<header class="md:flex items-center gap-2 pb-3">
<h1
class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate mb-2 md:mb-0"
>
<div class="flex items-center gap-2 w-full mb-2 md:mb-0">
${this.collection?.isPublic
? html`
<sl-tooltip content=${msg("Shareable")}>
<sl-icon class="text-lg" name="people-fill"></sl-icon>
</sl-tooltip>
`
: html`
<sl-tooltip content=${msg("Private")}>
<sl-icon class="text-lg" name="eye-slash-fill"></sl-icon>
</sl-tooltip>
`}
<h1 class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate">
${this.collection?.name ||
html`<sl-skeleton class="w-96"></sl-skeleton>`}
</h1>
</div>
${when(
this.collection?.isPublic,
() => html`
<sl-button size="small" @click=${() => (this.showEmbedInfo = true)}>
<sl-icon name="code-slash"></sl-icon>
View Embed Code
</sl-button>
`
)}
${when(this.isCrawler, this.renderActions)}
</header>
<div class="border rounded-lg py-2 mb-3">${this.renderInfoBar()}</div>
@ -135,9 +158,85 @@ export class CollectionDetail extends LiteElement {
>Delete Collection</sl-button
>
</div>
</btrix-dialog>`;
</btrix-dialog>
${this.renderShareInfo()}`;
}
private getPublicReplayURL() {
return new URL(
`/api/orgs/${this.orgId}/collections/${this.collectionId}/public/replay.json`,
window.location.href
).href;
}
private renderShareInfo = () => {
if (!this.collection?.isPublic) {
return;
}
const embedCode = `<replay-web-page source="${this.getPublicReplayURL()}"></replay-web-page>`;
const importCode = `importScripts("https://replayweb.page/sw.js");`;
return html` <btrix-dialog
label=${msg(str`Embed Code for “${this.collection?.name}`)}
?open=${this.showEmbedInfo}
@sl-request-close=${() => (this.showEmbedInfo = false)}
>
<div class="text-left">
<p class="mb-5">
${msg(
html`Embed this collection in another site using these
<strong class="font-medium">ReplayWeb.page</strong> code snippets.`
)}
</p>
<p class="mb-3">
${msg(html`Add the following embed code to your HTML page:`)}
</p>
<div class="relative">
<pre
class="whitespace-pre-wrap mb-5 rounded p-4 bg-slate-50 text-slate-600 text-[0.9em]"
><code>${embedCode}</code></pre>
<div class="absolute top-0 right-0">
<btrix-copy-button
.getValue=${() => embedCode}
content=${msg("Copy Embed Code")}
></btrix-copy-button>
</div>
</div>
<p class="mb-3">
${msg(
html`Add the following JavaScript to
<code class="text-[0.9em]">./replay/sw.js</code>:`
)}
</p>
<div class="relative">
<pre
class="whitespace-pre-wrap mb-5 rounded p-4 bg-slate-50 text-slate-600 text-[0.9em]"
><code>${importCode}</code></pre>
<div class="absolute top-0 right-0">
<btrix-copy-button
.getValue=${() => importCode}
content=${msg("Copy JS")}
></btrix-copy-button>
</div>
</div>
<p>
${msg(
html`See
<a
class="text-primary"
href="https://replayweb.page/docs/embedding"
target="_blank"
>
our embedding guide</a
>
for more details.`
)}
</p>
</div>
</btrix-dialog>`;
};
private renderHeader = () => html`
<nav class="mb-7">
<a
@ -197,6 +296,35 @@ export class CollectionDetail extends LiteElement {
${msg("Edit Collection")}
</sl-menu-item>
<sl-divider></sl-divider>
${!this.collection?.isPublic
? html`
<sl-menu-item
style="--sl-color-neutral-700: var(--success)"
@click=${() => this.onTogglePublic(true)}
>
<sl-icon name="people-fill" slot="prefix"></sl-icon>
${msg("Make Shareable")}
</sl-menu-item>
`
: html`
<sl-menu-item style="--sl-color-neutral-700: var(--success)">
<sl-icon name="box-arrow-up-right" slot="prefix"></sl-icon>
<a
target="_blank"
slot="prefix"
href="https://replayweb.page?source=${this.getPublicReplayURL()}"
>
Go to Public View
</a>
</sl-menu-item>
<sl-menu-item
style="--sl-color-neutral-700: var(--warning)"
@click=${() => this.onTogglePublic(false)}
>
<sl-icon name="eye-slash" slot="prefix"></sl-icon>
${msg("Make Private")}
</sl-menu-item>
`}
<!-- Shoelace doesn't allow "href" on menu items,
see https://github.com/shoelace-style/shoelace/issues/1351 -->
<a
@ -480,6 +608,21 @@ export class CollectionDetail extends LiteElement {
}
};
private async onTogglePublic(isPublic: boolean) {
const res = await this.apiFetch(
`/orgs/${this.orgId}/collections/${this.collectionId}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({ isPublic }),
}
);
if (res.updated && this.collection) {
this.collection = { ...this.collection, isPublic };
}
}
private confirmDelete = () => {
this.openDialogName = "delete";
};

View File

@ -83,7 +83,8 @@ export class CollectionEdit extends LiteElement {
private async onSubmit(e: CollectionSubmitEvent) {
this.isSubmitting = true;
const { name, description, crawlIds, oldCrawlIds } = e.detail.values;
const { name, description, crawlIds, oldCrawlIds, isPublic } =
e.detail.values;
try {
if (oldCrawlIds && oldCrawlIds) {
@ -92,7 +93,11 @@ export class CollectionEdit extends LiteElement {
oldCrawlIds,
});
} else {
await this.saveMetadata({ name, description });
await this.saveMetadata({
name,
description,
isPublic: isPublic === "on",
});
}
this.navTo(`/orgs/${this.orgId}/collections/view/${this.collectionId}`);
@ -117,7 +122,11 @@ export class CollectionEdit extends LiteElement {
this.isSubmitting = false;
}
private saveMetadata(values: { name: string; description: string | null }) {
private saveMetadata(values: {
name: string;
description: string | null;
isPublic: boolean;
}) {
return this.apiFetch(
`/orgs/${this.orgId}/collections/${this.collectionId}`,
this.authState!,

View File

@ -84,6 +84,7 @@ export type CollectionSubmitEvent = CustomEvent<{
description: string | null;
crawlIds: string[];
oldCrawlIds?: string[];
isPublic: string | null;
};
}>;
@ -512,6 +513,11 @@ export class CollectionEditor extends LiteElement {
maxlength=${4000}
></btrix-markdown-editor>
</fieldset>
<label>
<sl-switch name="isPublic" ?checked=${this.metadataValues?.isPublic}
>Publicly Accessible</sl-switch
>
</label>
</div>
<footer class="border-t px-6 py-4 flex justify-between">
${when(

View File

@ -454,6 +454,23 @@ export class CollectionsList extends LiteElement {
class="block text-primary hover:text-indigo-500"
@click=${this.navLink}
>
${col?.isPublic
? html`
<sl-tooltip content=${msg("Shareable")}>
<sl-icon
style="margin-right: 4px; vertical-align: bottom; font-size: 14px;"
name="people-fill"
></sl-icon>
</sl-tooltip>
`
: html`
<sl-tooltip content=${msg("Private")}>
<sl-icon
style="margin-right: 4px; vertical-align: bottom; font-size: 14px;"
name="eye-slash-fill"
></sl-icon>
</sl-tooltip>
`}
${col.name}
</a>
</div>
@ -523,6 +540,37 @@ export class CollectionsList extends LiteElement {
${msg("Edit Collection")}
</sl-menu-item>
<sl-divider></sl-divider>
${!col?.isPublic
? html`
<sl-menu-item
style="--sl-color-neutral-700: var(--success)"
@click=${() => this.onTogglePublic(col, true)}
>
<sl-icon name="people-fill" slot="prefix"></sl-icon>
${msg("Make Shareable")}
</sl-menu-item>
`
: html`
<sl-menu-item style="--sl-color-neutral-700: var(--success)">
<sl-icon name="box-arrow-up-right" slot="prefix"></sl-icon>
<a
target="_blank"
slot="prefix"
href="https://replayweb.page?source=${this.getPublicReplayURL(
col
)}"
>
Go to Shared View
</a>
</sl-menu-item>
<sl-menu-item
style="--sl-color-neutral-700: var(--warning)"
@click=${() => this.onTogglePublic(col, false)}
>
<sl-icon name="eye-slash" slot="prefix"></sl-icon>
${msg("Make Private")}
</sl-menu-item>
`}
<!-- Shoelace doesn't allow "href" on menu items,
see https://github.com/shoelace-style/shoelace/issues/1351 -->
<a
@ -572,6 +620,26 @@ export class CollectionsList extends LiteElement {
}
}) as any;
private async onTogglePublic(coll: Collection, isPublic: boolean) {
const res = await this.apiFetch(
`/orgs/${this.orgId}/collections/${coll.id}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({ isPublic }),
}
);
this.fetchCollections();
}
private getPublicReplayURL(col: Collection) {
return new URL(
`/api/orgs/${this.orgId}/collections/${col.id}/public/replay.json`,
window.location.href
).href;
}
private confirmDelete = (collection: Collection) => {
this.collectionToDelete = collection;
this.openDialogName = "delete";

View File

@ -59,12 +59,18 @@ export class CollectionsNew extends LiteElement {
console.log("submit", e.detail.values);
try {
const { name, description, crawlIds, isPublic } = e.detail.values;
const data = await this.apiFetch(
`/orgs/${this.orgId}/collections`,
this.authState!,
{
method: "POST",
body: JSON.stringify(e.detail.values),
body: JSON.stringify({
name,
description,
crawlIds,
public: isPublic === "on",
}),
}
);

View File

@ -9,6 +9,7 @@ export type Collection = {
totalSize: number;
tags: string[];
resources: string[];
isPublic: boolean;
};
export type CollectionList = Collection[];