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:
parent
62d3399223
commit
362afa47bd
@ -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,
|
||||
|
@ -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]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
@ -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(
|
||||
|
@ -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"])
|
||||
|
@ -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";
|
||||
};
|
||||
|
@ -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!,
|
||||
|
@ -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(
|
||||
|
@ -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";
|
||||
|
@ -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",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -9,6 +9,7 @@ export type Collection = {
|
||||
totalSize: number;
|
||||
tags: string[];
|
||||
resources: string[];
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export type CollectionList = Collection[];
|
||||
|
Loading…
Reference in New Issue
Block a user