diff --git a/backend/btrixcloud/colls.py b/backend/btrixcloud/colls.py index cfef43d1d5..0ea137a0d3 100644 --- a/backend/btrixcloud/colls.py +++ b/backend/btrixcloud/colls.py @@ -3,7 +3,7 @@ """ # pylint: disable=too-many-lines - +from datetime import datetime from collections import Counter from uuid import UUID, uuid4 from typing import Optional, List, TYPE_CHECKING, cast, Dict, Tuple, Any, Union @@ -20,10 +20,12 @@ from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .models import ( + AnyHttpUrl, Collection, CollIn, CollOut, CollIdName, + CollectionThumbnailSource, UpdateColl, AddRemoveCrawlList, BaseCrawl, @@ -753,7 +755,7 @@ async def list_urls_in_collection( page_size: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Tuple[List[PageUrlCount], int]: - """List all URLs in collection sorted desc by snapshot count""" + """List all URLs in collection sorted desc by snapshot count unless prefix is specified""" # pylint: disable=duplicate-code, too-many-locals, too-many-branches, too-many-statements # Zero-index page for query page = page - 1 @@ -762,13 +764,15 @@ async def list_urls_in_collection( crawl_ids = await self.get_collection_crawl_ids(coll_id) match_query: dict[str, object] = {"oid": oid, "crawl_id": {"$in": crawl_ids}} + sort_query: dict[str, int] = {"count": -1, "_id": 1} if url_prefix: url_prefix = urllib.parse.unquote(url_prefix) regex_pattern = f"^{re.escape(url_prefix)}" match_query["url"] = {"$regex": regex_pattern, "$options": "i"} + sort_query = {"_id": 1} - aggregate = [{"$match": match_query}] + aggregate: List[Dict[str, Union[int, object]]] = [{"$match": match_query}] aggregate.extend( [ @@ -779,7 +783,7 @@ async def list_urls_in_collection( "count": {"$sum": 1}, }, }, - {"$sort": {"count": -1}}, + {"$sort": sort_query}, {"$set": {"url": "$_id"}}, { "$facet": { @@ -843,8 +847,17 @@ async def set_home_url( return {"updated": True} + # pylint: disable=too-many-locals async def upload_thumbnail_stream( - self, stream, filename: str, coll_id: UUID, org: Organization, user: User + self, + stream, + filename: str, + coll_id: UUID, + org: Organization, + user: User, + source_url: Optional[AnyHttpUrl] = None, + source_ts: Optional[datetime] = None, + source_page_id: Optional[UUID] = None, ) -> Dict[str, bool]: """Upload file as stream to use as collection thumbnail""" coll = await self.get_collection(coll_id) @@ -903,6 +916,13 @@ async def stream_iter(): coll.thumbnail = thumbnail_file + if source_url and source_ts and source_page_id: + coll.thumbnailSource = CollectionThumbnailSource( + url=source_url, + urlTs=source_ts, + urlPageId=source_page_id, + ) + # Update entire document to avoid bson.errors.InvalidDocument exception await self.collections.find_one_and_update( {"_id": coll_id, "oid": org.id}, @@ -1226,11 +1246,21 @@ async def upload_thumbnail_stream( request: Request, filename: str, coll_id: UUID, + sourceUrl: Optional[AnyHttpUrl], + sourceTs: Optional[datetime], + sourcePageId: Optional[UUID], org: Organization = Depends(org_crawl_dep), user: User = Depends(user_dep), ): return await colls.upload_thumbnail_stream( - request.stream(), filename, coll_id, org, user + request.stream(), + filename, + coll_id, + org, + user, + sourceUrl, + sourceTs, + sourcePageId, ) @app.delete( diff --git a/backend/btrixcloud/crawlconfigs.py b/backend/btrixcloud/crawlconfigs.py index 86e0e0c2b8..e65dbe29dd 100644 --- a/backend/btrixcloud/crawlconfigs.py +++ b/backend/btrixcloud/crawlconfigs.py @@ -251,11 +251,6 @@ async def add_crawl_config( crawlconfig.lastStartedBy = user.id crawlconfig.lastStartedByName = user.name - # Ensure page limit is below org maxPagesPerCall if set - max_pages = org.quotas.maxPagesPerCrawl or 0 - if max_pages > 0: - crawlconfig.config.limit = max_pages - # add CrawlConfig to DB here result = await self.crawl_configs.insert_one(crawlconfig.to_dict()) @@ -286,13 +281,30 @@ async def add_crawl_config( execMinutesQuotaReached=exec_mins_quota_reached, ) + def ensure_quota_page_limit(self, crawlconfig: CrawlConfig, org: Organization): + """ensure page limit is set to no greater than quota page limit, if any""" + if org.quotas.maxPagesPerCrawl and org.quotas.maxPagesPerCrawl > 0: + if crawlconfig.config.limit and crawlconfig.config.limit > 0: + crawlconfig.config.limit = min( + org.quotas.maxPagesPerCrawl, crawlconfig.config.limit + ) + else: + crawlconfig.config.limit = org.quotas.maxPagesPerCrawl + async def add_new_crawl( - self, crawl_id: str, crawlconfig: CrawlConfig, user: User, manual: bool + self, + crawl_id: str, + crawlconfig: CrawlConfig, + user: User, + org: Organization, + manual: bool, ) -> None: """increments crawl count for this config and adds new crawl""" started = dt_now() + self.ensure_quota_page_limit(crawlconfig, org) + inc = self.inc_crawl_count(crawlconfig.id) add = self.crawl_ops.add_new_crawl( crawl_id, crawlconfig, user.id, started, manual @@ -892,7 +904,7 @@ async def run_now_internal( storage_filename=storage_filename, profile_filename=profile_filename or "", ) - await self.add_new_crawl(crawl_id, crawlconfig, user, manual=True) + await self.add_new_crawl(crawl_id, crawlconfig, user, org, manual=True) return crawl_id except Exception as exc: diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index 507db08f02..618cfd1d78 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -248,7 +248,14 @@ def main() -> None: upload_ops = init_uploads_api(*base_crawl_init) page_ops = init_pages_api( - app, mdb, crawls, org_ops, storage_ops, background_job_ops, current_active_user + app, + mdb, + crawls, + org_ops, + storage_ops, + background_job_ops, + coll_ops, + current_active_user, ) base_crawl_ops.set_page_ops(page_ops) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 38734d7915..33f13415f9 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1236,6 +1236,15 @@ class CollAccessType(str, Enum): PUBLIC = "public" +# ============================================================================ +class CollectionThumbnailSource(BaseModel): + """The page source for a thumbnail""" + + url: AnyHttpUrl + urlTs: datetime + urlPageId: UUID + + # ============================================================================ class Collection(BaseMongoModel): """Org collection structure""" @@ -1268,6 +1277,7 @@ class Collection(BaseMongoModel): homeUrlPageId: Optional[UUID] = None thumbnail: Optional[ImageFile] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: Optional[bool] = True @@ -1323,6 +1333,7 @@ class CollOut(BaseMongoModel): resources: List[CrawlFileOut] = [] thumbnail: Optional[ImageFileOut] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: bool = True @@ -1372,6 +1383,7 @@ class UpdateColl(BaseModel): access: Optional[CollAccessType] = None defaultThumbnailName: Optional[str] = None allowPublicDownload: Optional[bool] = None + thumbnailSource: Optional[CollectionThumbnailSource] = None # ============================================================================ diff --git a/backend/btrixcloud/operator/crawls.py b/backend/btrixcloud/operator/crawls.py index 41e8f53ec1..91976cad42 100644 --- a/backend/btrixcloud/operator/crawls.py +++ b/backend/btrixcloud/operator/crawls.py @@ -6,6 +6,7 @@ from pprint import pprint from typing import Optional, Any, Sequence from datetime import datetime +from uuid import UUID import json @@ -29,7 +30,6 @@ CrawlFile, CrawlCompleteIn, StorageRef, - Organization, ) from btrixcloud.utils import str_to_date, date_to_str, dt_now @@ -145,11 +145,13 @@ async def sync_crawls(self, data: MCSyncData): params["userid"] = spec.get("userid", "") pods = data.children[POD] + org = await self.org_ops.get_org_by_id(UUID(oid)) crawl = CrawlSpec( id=crawl_id, cid=cid, oid=oid, + org=org, storage=StorageRef(spec["storageName"]), crawler_channel=spec.get("crawlerChannel"), proxy_id=spec.get("proxyId"), @@ -204,8 +206,6 @@ async def sync_crawls(self, data: MCSyncData): await self.k8s.delete_crawl_job(crawl.id) return {"status": status.dict(exclude_none=True), "children": []} - org = None - # first, check storage quota, and fail immediately if quota reached if status.state in ( "starting", @@ -215,7 +215,6 @@ async def sync_crawls(self, data: MCSyncData): # only check on very first run, before any pods/pvcs created # for now, allow if crawl has already started (pods/pvcs created) if not pods and not data.children[PVC]: - org = await self.org_ops.get_org_by_id(crawl.oid) if self.org_ops.storage_quota_reached(org): await self.mark_finished( crawl, status, "skipped_storage_quota_reached" @@ -229,7 +228,7 @@ async def sync_crawls(self, data: MCSyncData): return self._empty_response(status) if status.state in ("starting", "waiting_org_limit"): - if not await self.can_start_new(crawl, data, status, org): + if not await self.can_start_new(crawl, data, status): return self._empty_response(status) await self.set_state( @@ -382,8 +381,9 @@ async def _load_crawl_configmap(self, crawl: CrawlSpec, children, params): crawlconfig = await self.crawl_config_ops.get_crawl_config(crawl.cid, crawl.oid) - raw_config = crawlconfig.get_raw_config() + self.crawl_config_ops.ensure_quota_page_limit(crawlconfig, crawl.org) + raw_config = crawlconfig.get_raw_config() raw_config["behaviors"] = self._filter_autoclick_behavior( raw_config["behaviors"], params["crawler_image"] ) @@ -637,14 +637,10 @@ async def can_start_new( crawl: CrawlSpec, data: MCSyncData, status: CrawlStatus, - org: Optional[Organization] = None, ): """return true if crawl can start, otherwise set crawl to 'queued' state until more crawls for org finish""" - if not org: - org = await self.org_ops.get_org_by_id(crawl.oid) - - max_crawls = org.quotas.maxConcurrentCrawls or 0 + max_crawls = crawl.org.quotas.maxConcurrentCrawls or 0 if not max_crawls: return True @@ -1238,15 +1234,13 @@ def get_log_line(self, message, details): } return json.dumps(err) - async def add_file_to_crawl(self, cc_data, crawl, redis): + async def add_file_to_crawl(self, cc_data, crawl: CrawlSpec, redis): """Handle finished CrawlFile to db""" filecomplete = CrawlCompleteIn(**cc_data) - org = await self.org_ops.get_org_by_id(crawl.oid) - filename = self.storage_ops.get_org_relative_path( - org, crawl.storage, filecomplete.filename + crawl.org, crawl.storage, filecomplete.filename ) crawl_file = CrawlFile( @@ -1299,7 +1293,7 @@ async def is_crawl_stopping( return "size-limit" # gracefully stop crawl if current running crawl sizes reach storage quota - org = await self.org_ops.get_org_by_id(crawl.oid) + org = crawl.org if org.readOnly: return "stopped_org_readonly" diff --git a/backend/btrixcloud/operator/cronjobs.py b/backend/btrixcloud/operator/cronjobs.py index 018fae9af8..929d7920fb 100644 --- a/backend/btrixcloud/operator/cronjobs.py +++ b/backend/btrixcloud/operator/cronjobs.py @@ -112,6 +112,7 @@ async def make_new_crawljob( crawl_id, crawlconfig, user, + org, manual=False, ) print("Scheduled Crawl Created: " + crawl_id) diff --git a/backend/btrixcloud/operator/models.py b/backend/btrixcloud/operator/models.py index 439eeb262c..9f511ee75e 100644 --- a/backend/btrixcloud/operator/models.py +++ b/backend/btrixcloud/operator/models.py @@ -5,7 +5,7 @@ from typing import Optional, DefaultDict, Literal, Annotated, Any from pydantic import BaseModel, Field from kubernetes.utils import parse_quantity -from btrixcloud.models import StorageRef, TYPE_ALL_CRAWL_STATES +from btrixcloud.models import StorageRef, TYPE_ALL_CRAWL_STATES, Organization BTRIX_API = "btrix.cloud/v1" @@ -70,6 +70,7 @@ class CrawlSpec(BaseModel): id: str cid: UUID oid: UUID + org: Organization scale: int = 1 storage: StorageRef started: str diff --git a/backend/btrixcloud/ops.py b/backend/btrixcloud/ops.py index 7c0bf2943e..5a67acd072 100644 --- a/backend/btrixcloud/ops.py +++ b/backend/btrixcloud/ops.py @@ -89,7 +89,9 @@ def init_ops() -> Tuple[ upload_ops = UploadOps(*base_crawl_init) - page_ops = PageOps(mdb, crawl_ops, org_ops, storage_ops, background_job_ops) + page_ops = PageOps( + mdb, crawl_ops, org_ops, storage_ops, background_job_ops, coll_ops + ) base_crawl_ops.set_page_ops(page_ops) crawl_ops.set_page_ops(page_ops) diff --git a/backend/btrixcloud/pages.py b/backend/btrixcloud/pages.py index 4b53b5b9b5..ebabd2979c 100644 --- a/backend/btrixcloud/pages.py +++ b/backend/btrixcloud/pages.py @@ -1,8 +1,12 @@ """crawl pages""" +# pylint: disable=too-many-lines + import asyncio import os +import re import traceback +import urllib.parse from datetime import datetime from typing import TYPE_CHECKING, Optional, Tuple, List, Dict, Any, Union from uuid import UUID, uuid4 @@ -37,11 +41,12 @@ if TYPE_CHECKING: from .background_jobs import BackgroundJobOps + from .colls import CollectionOps from .crawls import CrawlOps from .orgs import OrgOps from .storages import StorageOps else: - CrawlOps = StorageOps = OrgOps = BackgroundJobOps = object + CrawlOps = StorageOps = OrgOps = BackgroundJobOps = CollectionOps = object # ============================================================================ @@ -53,14 +58,18 @@ class PageOps: org_ops: OrgOps storage_ops: StorageOps background_job_ops: BackgroundJobOps + coll_ops: CollectionOps - def __init__(self, mdb, crawl_ops, org_ops, storage_ops, background_job_ops): + def __init__( + self, mdb, crawl_ops, org_ops, storage_ops, background_job_ops, coll_ops + ): self.pages = mdb["pages"] self.crawls = mdb["crawls"] self.crawl_ops = crawl_ops self.org_ops = org_ops self.storage_ops = storage_ops self.background_job_ops = background_job_ops + self.coll_ops = coll_ops async def init_index(self): """init index for pages db collection""" @@ -82,6 +91,9 @@ async def add_crawl_pages_to_db_from_wacz(self, crawl_id: str, batch_size=100): if not page_dict.get("url"): continue + if not page_dict.get("isSeed"): + page_dict["isSeed"] = False + if len(pages_buffer) > batch_size: await self._add_pages_to_db(crawl_id, pages_buffer) pages_buffer = [] @@ -210,9 +222,8 @@ async def add_page_to_db( ): """Add page to database""" page = self._get_page_from_dict(page_dict, crawl_id, oid) - page_to_insert = page.to_dict( - exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + + page_to_insert = page.to_dict(exclude_unset=True, exclude_none=True) try: await self.pages.insert_one(page_to_insert) @@ -492,6 +503,11 @@ async def list_pages( self, crawl_id: str, org: Optional[Organization] = None, + url: Optional[str] = None, + url_prefix: Optional[str] = None, + ts: Optional[datetime] = None, + is_seed: Optional[bool] = None, + depth: Optional[int] = None, qa_run_id: Optional[str] = None, qa_filter_by: Optional[str] = None, qa_gte: Optional[float] = None, @@ -518,6 +534,23 @@ async def list_pages( if org: query["oid"] = org.id + if url_prefix: + url_prefix = urllib.parse.unquote(url_prefix) + regex_pattern = f"^{re.escape(url_prefix)}" + query["url"] = {"$regex": regex_pattern, "$options": "i"} + + elif url: + query["url"] = urllib.parse.unquote(url) + + if ts: + query["ts"] = ts + + if is_seed in (True, False): + query["isSeed"] = is_seed + + if isinstance(depth, int): + query["depth"] = depth + if reviewed: query["$or"] = [ {"approved": {"$ne": None}}, @@ -562,7 +595,18 @@ async def list_pages( # Sorting options to add: # - automated heuristics like screenshot_comparison (dict keyed by QA run id) # - Ensure notes sorting works okay with notes in list - sort_fields = ("url", "title", "notes", "approved") + sort_fields = ( + "url", + "title", + "notes", + "approved", + "ts", + "status", + "mime", + "filename", + "depth", + "isSeed", + ) qa_sort_fields = ("screenshotMatch", "textMatch") if sort_by not in sort_fields and sort_by not in qa_sort_fields: raise HTTPException(status_code=400, detail="invalid_sort_by") @@ -613,6 +657,101 @@ async def list_pages( return [PageOut.from_dict(data) for data in items], total + async def list_collection_pages( + self, + coll_id: UUID, + org: Optional[Organization] = None, + url: Optional[str] = None, + url_prefix: Optional[str] = None, + ts: Optional[datetime] = None, + is_seed: Optional[bool] = None, + depth: Optional[int] = None, + page_size: int = DEFAULT_PAGE_SIZE, + page: int = 1, + sort_by: Optional[str] = None, + sort_direction: Optional[int] = -1, + ) -> Tuple[Union[List[PageOut], List[PageOutWithSingleQA]], int]: + """List all pages in collection, with optional filtering""" + # pylint: disable=duplicate-code, too-many-locals, too-many-branches, too-many-statements + # Zero-index page for query + page = page - 1 + skip = page_size * page + + crawl_ids = await self.coll_ops.get_collection_crawl_ids(coll_id) + + query: dict[str, object] = { + "crawl_id": {"$in": crawl_ids}, + } + if org: + query["oid"] = org.id + + if url_prefix: + url_prefix = urllib.parse.unquote(url_prefix) + regex_pattern = f"^{re.escape(url_prefix)}" + query["url"] = {"$regex": regex_pattern, "$options": "i"} + + elif url: + query["url"] = urllib.parse.unquote(url) + + if ts: + query["ts"] = ts + + if is_seed in (True, False): + query["isSeed"] = is_seed + + if isinstance(depth, int): + query["depth"] = depth + + aggregate = [{"$match": query}] + + if sort_by: + # Sorting options to add: + # - automated heuristics like screenshot_comparison (dict keyed by QA run id) + # - Ensure notes sorting works okay with notes in list + sort_fields = ( + "url", + "crawl_id", + "ts", + "status", + "mime", + "filename", + "depth", + "isSeed", + ) + if sort_by not in sort_fields: + raise HTTPException(status_code=400, detail="invalid_sort_by") + if sort_direction not in (1, -1): + raise HTTPException(status_code=400, detail="invalid_sort_direction") + + aggregate.extend([{"$sort": {sort_by: sort_direction}}]) + + aggregate.extend( + [ + { + "$facet": { + "items": [ + {"$skip": skip}, + {"$limit": page_size}, + ], + "total": [{"$count": "count"}], + } + }, + ] + ) + + # Get total + cursor = self.pages.aggregate(aggregate) + results = await cursor.to_list(length=1) + result = results[0] + items = result["items"] + + try: + total = int(result["total"][0]["count"]) + except (IndexError, ValueError): + total = 0 + + return [PageOut.from_dict(data) for data in items], total + async def re_add_crawl_pages(self, crawl_id: str, oid: UUID): """Delete existing pages for crawl and re-add from WACZs.""" await self.delete_crawl_pages(crawl_id, oid) @@ -738,13 +877,14 @@ async def set_archived_item_page_counts(self, crawl_id: str): # ============================================================================ # pylint: disable=too-many-arguments, too-many-locals, invalid-name, fixme def init_pages_api( - app, mdb, crawl_ops, org_ops, storage_ops, background_job_ops, user_dep + app, mdb, crawl_ops, org_ops, storage_ops, background_job_ops, coll_ops, user_dep ): """init pages API""" # pylint: disable=invalid-name - ops = PageOps(mdb, crawl_ops, org_ops, storage_ops, background_job_ops) + ops = PageOps(mdb, crawl_ops, org_ops, storage_ops, background_job_ops, coll_ops) + org_viewer_dep = org_ops.org_viewer_dep org_crawl_dep = org_ops.org_crawl_dep @app.post( @@ -913,9 +1053,14 @@ async def delete_page_notes( tags=["pages", "all-crawls"], response_model=PaginatedPageOutResponse, ) - async def get_pages_list( + async def get_crawl_pages_list( crawl_id: str, org: Organization = Depends(org_crawl_dep), + url: Optional[str] = None, + urlPrefix: Optional[str] = None, + ts: Optional[datetime] = None, + isSeed: Optional[bool] = None, + depth: Optional[int] = None, reviewed: Optional[bool] = None, approved: Optional[str] = None, hasNotes: Optional[bool] = None, @@ -932,6 +1077,11 @@ async def get_pages_list( pages, total = await ops.list_pages( crawl_id=crawl_id, org=org, + url=url, + url_prefix=urlPrefix, + ts=ts, + is_seed=isSeed, + depth=depth, reviewed=reviewed, approved=formatted_approved, has_notes=hasNotes, @@ -942,6 +1092,40 @@ async def get_pages_list( ) return paginated_format(pages, total, page, pageSize) + @app.get( + "/orgs/{oid}/collections/{coll_id}/pages", + tags=["pages", "collections"], + response_model=PaginatedPageOutResponse, + ) + async def get_collection_pages_list( + coll_id: UUID, + org: Organization = Depends(org_viewer_dep), + url: Optional[str] = None, + urlPrefix: Optional[str] = None, + ts: Optional[datetime] = None, + isSeed: Optional[bool] = None, + depth: Optional[int] = None, + pageSize: int = DEFAULT_PAGE_SIZE, + page: int = 1, + sortBy: Optional[str] = None, + sortDirection: Optional[int] = -1, + ): + """Retrieve paginated list of pages in collection""" + pages, total = await ops.list_collection_pages( + coll_id=coll_id, + org=org, + url=url, + url_prefix=urlPrefix, + ts=ts, + is_seed=isSeed, + depth=depth, + page_size=pageSize, + page=page, + sort_by=sortBy, + sort_direction=sortDirection, + ) + return paginated_format(pages, total, page, pageSize) + @app.get( "/orgs/{oid}/crawls/{crawl_id}/qa/{qa_run_id}/pages", tags=["pages", "qa"], diff --git a/backend/test/conftest.py b/backend/test/conftest.py index d2f4192366..b9a6e71725 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -232,7 +232,7 @@ def crawler_crawl_id(crawler_auth_headers, default_org_id): "name": "Crawler User Test Crawl", "description": "crawler test crawl", "tags": ["wr-test-2"], - "config": {"seeds": [{"url": "https://webrecorder.net/"}], "limit": 1}, + "config": {"seeds": [{"url": "https://webrecorder.net/"}], "limit": 3}, "crawlerChannel": "test", } r = requests.post( diff --git a/backend/test/test_collections.py b/backend/test/test_collections.py index e219134b95..bdeaae2345 100644 --- a/backend/test/test_collections.py +++ b/backend/test/test_collections.py @@ -582,6 +582,121 @@ def test_list_collections( assert second_coll["dateLatest"] +def test_list_pages_in_collection(crawler_auth_headers, default_org_id): + # Test list endpoint + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 0 + + pages = data["items"] + assert pages + + for page in pages: + assert page["id"] + assert page["oid"] + assert page["crawl_id"] + assert page["url"] + assert page["ts"] + assert page.get("title") or page.get("title") is None + assert page.get("loadState") or page.get("loadState") is None + assert page.get("status") or page.get("status") is None + assert page.get("mime") or page.get("mime") is None + assert page["isError"] in (None, True, False) + assert page["isFile"] in (None, True, False) + + # Save info for page to test url and urlPrefix filters + coll_page = pages[0] + coll_page_id = coll_page["id"] + coll_page_url = coll_page["url"] + coll_page_ts = coll_page["ts"] + + # Test exact url filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?url={coll_page_url}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + for matching_page in data["items"]: + assert matching_page["url"] == coll_page_url + + # Test exact url and ts filters together + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?url={coll_page_url}&ts={coll_page_ts}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + for matching_page in data["items"]: + assert matching_page["url"] == coll_page_url + assert matching_page["ts"] == coll_page_ts + + # Test urlPrefix filter + url_prefix = coll_page_url[:8] + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?urlPrefix={url_prefix}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + + found_matching_page = False + for page in data["items"]: + if page["id"] == coll_page_id and page["url"] == coll_page_url: + found_matching_page = True + + assert found_matching_page + + # Test isSeed filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?isSeed=true", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + for page in data["items"]: + assert page["isSeed"] + + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?isSeed=false", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + for page in data["items"]: + assert page["isSeed"] is False + + # Test depth filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?depth=0", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + for page in data["items"]: + assert page["depth"] == 0 + + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_coll_id}/pages?depth=1", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + for page in data["items"]: + assert page["depth"] == 1 + + def test_remove_upload_from_collection(crawler_auth_headers, default_org_id): # Remove upload r = requests.post( @@ -1030,9 +1145,10 @@ def test_collection_url_list(crawler_auth_headers, default_org_id): def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): + # https://dev.browsertrix.com/api/orgs/c69247f4-415e-4abc-b449-e85d2f26c626/collections/b764fbe1-baab-4dc5-8dca-2db6f82c250b/thumbnail?filename=page-thumbnail_47fe599e-ed62-4edd-b078-93d4bf281e0f.jpeg&sourceUrl=https%3A%2F%2Fspecs.webrecorder.net%2F&sourceTs=2024-08-16T08%3A00%3A21.601000Z&sourcePageId=47fe599e-ed62-4edd-b078-93d4bf281e0f with open(os.path.join(curr_dir, "data", "thumbnail.jpg"), "rb") as fh: r = requests.put( - f"{API_PREFIX}/orgs/{default_org_id}/collections/{_public_coll_id}/thumbnail?filename=thumbnail.jpg", + f"{API_PREFIX}/orgs/{default_org_id}/collections/{_public_coll_id}/thumbnail?filename=thumbnail.jpg&sourceUrl=https%3A%2F%2Fexample.com%2F&sourceTs=2024-08-16T08%3A00%3A21.601000Z&sourcePageId=1bba4aba-d5be-4943-ad48-d6710633d754", headers=crawler_auth_headers, data=read_in_chunks(fh), ) @@ -1044,7 +1160,8 @@ def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): headers=crawler_auth_headers, ) assert r.status_code == 200 - thumbnail = r.json()["thumbnail"] + collection = r.json() + thumbnail = collection["thumbnail"] assert thumbnail["name"] assert thumbnail["path"] @@ -1057,6 +1174,16 @@ def test_upload_collection_thumbnail(crawler_auth_headers, default_org_id): assert thumbnail["userName"] assert thumbnail["created"] + thumbnailSource = collection["thumbnailSource"] + + assert thumbnailSource["url"] + assert thumbnailSource["urlTs"] + assert thumbnailSource["urlPageId"] + + assert thumbnailSource["url"] == "https://example.com/" + assert thumbnailSource["urlTs"] == "2024-08-16T08:00:21.601000Z" + assert thumbnailSource["urlPageId"] == "1bba4aba-d5be-4943-ad48-d6710633d754" + def test_set_collection_default_thumbnail(crawler_auth_headers, default_org_id): default_thumbnail_name = "orange-default.avif" diff --git a/backend/test/test_run_crawl.py b/backend/test/test_run_crawl.py index 511c4c6c1e..51cec3cb82 100644 --- a/backend/test/test_run_crawl.py +++ b/backend/test/test_run_crawl.py @@ -658,7 +658,8 @@ def test_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): ) assert r.status_code == 200 data = r.json() - assert data["total"] >= 0 + + assert data["total"] == 3 pages = data["items"] assert pages @@ -682,7 +683,11 @@ def test_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): # Test GET page endpoint global page_id - page_id = pages[0]["id"] + test_page = pages[0] + page_id = test_page["id"] + test_page_url = test_page["url"] + test_page_ts = test_page["ts"] + r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages/{page_id}", headers=crawler_auth_headers, @@ -710,13 +715,100 @@ def test_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): assert page.get("modified") is None assert page.get("approved") is None + # Test exact url filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?url={test_page_url}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + for matching_page in data["items"]: + assert matching_page["url"] == test_page_url + + # Test exact url and ts filters together + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?url={test_page_url}&ts={test_page_ts}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + for matching_page in data["items"]: + assert matching_page["url"] == test_page_url + assert matching_page["ts"] == test_page_ts + + # Test urlPrefix filter + url_prefix = test_page_url[:8] + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?urlPrefix={url_prefix}", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] >= 1 + + found_matching_page = False + for page in data["items"]: + if page["id"] == page_id and page["url"] == test_page_url: + found_matching_page = True + + assert found_matching_page + + # Test isSeed filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?isSeed=True", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert data["total"] == 1 + for page in data["items"]: + assert page["isSeed"] + + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?isSeed=False", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert data["total"] == 2 + for page in data["items"]: + assert page["isSeed"] is False + + # Test depth filter + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?depth=0", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert data["total"] == 1 + for page in data["items"]: + assert page["depth"] == 0 + + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?depth=1", + headers=crawler_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert data["total"] == 2 + for page in data["items"]: + assert page["depth"] == 1 + + +def test_crawl_pages_qa_filters(crawler_auth_headers, default_org_id, crawler_crawl_id): # Test reviewed filter (page has no notes or approved so should show up in false) r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?reviewed=False", headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 1 + assert r.json()["total"] == 3 r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?reviewed=True", @@ -770,15 +862,15 @@ def test_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 0 + assert r.json()["total"] == 2 - # Test reviewed filter (page now approved so should show up in True) + # Test reviewed filter (page now approved so should show up in True, other pages show here) r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?reviewed=False", headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 0 + assert r.json()["total"] == 2 r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?reviewed=True", @@ -853,7 +945,7 @@ def test_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 0 + assert r.json()["total"] == 2 def test_re_add_crawl_pages(crawler_auth_headers, default_org_id, crawler_crawl_id): @@ -985,14 +1077,14 @@ def test_crawl_page_notes(crawler_auth_headers, default_org_id, crawler_crawl_id headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 1 + assert r.json()["total"] == 3 r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?approved=true,false,none", headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 1 + assert r.json()["total"] == 3 # Test reviewed filter (page now has notes so should show up in True) r = requests.get( @@ -1000,7 +1092,7 @@ def test_crawl_page_notes(crawler_auth_headers, default_org_id, crawler_crawl_id headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 0 + assert r.json()["total"] == 2 r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?reviewed=True", @@ -1015,7 +1107,7 @@ def test_crawl_page_notes(crawler_auth_headers, default_org_id, crawler_crawl_id headers=crawler_auth_headers, ) assert r.status_code == 200 - assert r.json()["total"] == 0 + assert r.json()["total"] == 2 r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawls/{crawler_crawl_id}/pages?hasNotes=True", diff --git a/frontend/docs/docs/user-guide/org-settings.md b/frontend/docs/docs/user-guide/org-settings.md index a2a6a46b86..bd4a246aa5 100644 --- a/frontend/docs/docs/user-guide/org-settings.md +++ b/frontend/docs/docs/user-guide/org-settings.md @@ -1,4 +1,4 @@ -# Change Org Settings +# Edit Org Settings Settings that apply to the entire organization are found in the **Settings** page. If you're an org admin, you'll see the link to _Settings_ in the org navigation bar. @@ -13,7 +13,7 @@ The org URL is where you and other org members will go to view the dashboard, co 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 if either are already in use by another org. ??? info "What information will be visible to the public?" - All org information is private until you make the org visible. Once your org is made visible to the public, the org name, description, and website will appear on the org's public gallery page. You can preview how information appears to the public by clicking **View as public**. + All org information is private until you make the org visible. Once your org is made visible to the public, the org name, description, and website will appear on the org's public collections gallery page. You can preview how information appears to the public by visiting the linked public collections gallery page. ### Public Collections Gallery diff --git a/frontend/docs/docs/user-guide/workflow-setup.md b/frontend/docs/docs/user-guide/workflow-setup.md index 49ab0b5284..4efdf7a18b 100644 --- a/frontend/docs/docs/user-guide/workflow-setup.md +++ b/frontend/docs/docs/user-guide/workflow-setup.md @@ -157,10 +157,16 @@ Waits on the page after initial HTML page load for a set number of seconds prior Limits amount of elapsed time behaviors have to complete. -### Auto Scroll Behavior +### Autoscroll Behavior When enabled, the browser will automatically scroll to the end of the page. +### Autoclick Behavior + +When enabled, the browser will automatically click on all links, even if they're empty or don't navigate to another page. + +This can be helpful for web applications that use JavaScript to handle navigation and don't link to things properly with `href=""` attributes. + ### Delay Before Next Page Waits on the page for a set period of elapsed time after any behaviors have finished running. This can be helpful to avoid rate limiting however it will slow down your crawl. @@ -253,10 +259,6 @@ Sets the date of the month for which crawls scheduled with a `Monthly` _Frequenc Sets the time that the scheduled crawl will start according to your current timezone. -### Also Run a Crawl Immediately On Save - -When enabled, a crawl will run immediately on save as if the `Run Immediately on Save` _Crawl Schedule Type_ was selected, in addition to scheduling a crawl to run according to the above settings. - ## Metadata Describe and organize your crawl workflow and the resulting archived items. @@ -275,8 +277,4 @@ Apply tags to the workflow. Tags applied to the workflow will propagate to every ### Collection Auto-Add -Search for and specify [collections](collection.md) that this crawl workflow should automatically add archived items to as soon as crawling finishes. Canceled and Failed crawls will not be added to collections. - -## Review Settings - -This section lists all the previously entered settings for final review. If there are any errors from the previous form sections, they will be listed at the top. The errors need to be corrected before the crawl workflow can be created. +Search for and specify [collections](collections.md) that this crawl workflow should automatically add archived items to as soon as crawling finishes. Canceled and Failed crawls will not be added to collections. diff --git a/frontend/src/components/ui/button.ts b/frontend/src/components/ui/button.ts index f06bd3d4e5..a03b1aa6d2 100644 --- a/frontend/src/components/ui/button.ts +++ b/frontend/src/components/ui/button.ts @@ -74,7 +74,8 @@ export class Button extends TailwindElement { small: tw`min-h-6 min-w-6 rounded-md text-base`, medium: tw`min-h-8 min-w-8 rounded-sm text-lg`, }[this.size], - this.raised && tw`shadow ring-1 ring-neutral-200`, + this.raised && + tw`shadow ring-1 ring-stone-500/20 hover:shadow-stone-800/20 hover:ring-stone-800/20`, this.filled ? [ tw`text-white`, diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index 5eb2d7f662..28731786cc 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -14,10 +14,10 @@ import sectionStrings from "@/strings/crawl-workflows/section"; import type { Collection } from "@/types/collection"; import { WorkflowScopeType } from "@/types/workflow"; import { isApiError } from "@/utils/api"; -import { getAppSettings } from "@/utils/app"; import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler"; import { humanizeSchedule } from "@/utils/cron"; import { pluralOf } from "@/utils/pluralize"; +import { getServerDefaults } from "@/utils/workflow"; /** * Usage: @@ -55,7 +55,7 @@ export class ConfigDetails extends BtrixElement { async connectedCallback() { super.connectedCallback(); - void this.fetchAPIDefaults(); + void this.fetchOrgDefaults(); await this.fetchCollections(); } @@ -137,7 +137,9 @@ export class ConfigDetails extends BtrixElement { if (this.orgDefaults?.maxPagesPerCrawl) { return html` - ${this.localize.number(this.orgDefaults.maxPagesPerCrawl)} + ${this.orgDefaults.maxPagesPerCrawl === Infinity + ? msg("Unlimited") + : this.localize.number(this.orgDefaults.maxPagesPerCrawl)} ${pluralOf("pages", this.orgDefaults.maxPagesPerCrawl)} ${msg("(default)")}`; @@ -174,7 +176,7 @@ export class ConfigDetails extends BtrixElement { ), )} ${this.renderSetting( - msg("Auto-Scroll Behavior"), + msg("Autoscroll Behavior"), crawlConfig?.config.behaviors && !crawlConfig.config.behaviors.includes("autoscroll") ? msg("Disabled") @@ -182,6 +184,15 @@ export class ConfigDetails extends BtrixElement { >${msg("Enabled (default)")}`, )} + ${this.renderSetting( + msg("Autoclick Behavior"), + crawlConfig?.config.behaviors && + crawlConfig.config.behaviors.includes("autoclick") + ? msg("Enabled") + : html`${msg("Disabled (default)")}`, + )} ${this.renderSetting( msg("Delay Before Next Page"), renderTimeLimit(crawlConfig?.config.pageExtraDelay, 0), @@ -510,25 +521,29 @@ export class ConfigDetails extends BtrixElement { this.requestUpdate(); } - private async fetchAPIDefaults() { + // TODO Consolidate with workflow-editor + private async fetchOrgDefaults() { try { - const settings = await getAppSettings(); - const orgDefaults = { + const [serverDefaults, { quotas }] = await Promise.all([ + getServerDefaults(), + this.api.fetch<{ + quotas: { maxPagesPerCrawl?: number }; + }>(`/orgs/${this.orgId}`), + ]); + + const defaults = { ...this.orgDefaults, + ...serverDefaults, }; - if (settings.defaultBehaviorTimeSeconds > 0) { - orgDefaults.behaviorTimeoutSeconds = - settings.defaultBehaviorTimeSeconds; - } - if (settings.defaultPageLoadTimeSeconds > 0) { - orgDefaults.pageLoadTimeoutSeconds = - settings.defaultPageLoadTimeSeconds; - } - if (settings.maxPagesPerCrawl > 0) { - orgDefaults.maxPagesPerCrawl = settings.maxPagesPerCrawl; + if (defaults.maxPagesPerCrawl && quotas.maxPagesPerCrawl) { + defaults.maxPagesPerCrawl = Math.min( + defaults.maxPagesPerCrawl, + quotas.maxPagesPerCrawl, + ); } - this.orgDefaults = orgDefaults; + + this.orgDefaults = defaults; } catch (e) { console.debug(e); } diff --git a/frontend/src/components/ui/copy-field.ts b/frontend/src/components/ui/copy-field.ts index bedb53d723..fbedc0152d 100644 --- a/frontend/src/components/ui/copy-field.ts +++ b/frontend/src/components/ui/copy-field.ts @@ -66,7 +66,7 @@ export class CopyField extends TailwindElement { role="group" class=${clsx( tw`rounded border`, - this.filled && tw`bg-slate-50`, + this.filled ? tw`bg-slate-50` : tw`border-neutral-150`, this.monostyle && tw`font-monostyle`, )} > diff --git a/frontend/src/components/ui/navigation/navigation-button.ts b/frontend/src/components/ui/navigation/navigation-button.ts index c9de311512..b3da1a9b30 100644 --- a/frontend/src/components/ui/navigation/navigation-button.ts +++ b/frontend/src/components/ui/navigation/navigation-button.ts @@ -1,5 +1,6 @@ /* eslint-disable lit/binding-positions */ /* eslint-disable lit/no-invalid-html */ +import clsx from "clsx"; import { css, type PropertyValueMap } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -26,6 +27,9 @@ export class NavigationButton extends TailwindElement { @property({ type: String }) type: "submit" | "button" = "button"; + @property({ type: String }) + variant: "primary" | "error" = "primary"; // TODO expand if necessary + @property({ type: String }) label?: string; @@ -76,8 +80,9 @@ export class NavigationButton extends TailwindElement { return html`<${tag} type=${this.type === "submit" ? "submit" : "button"} part="button" - class=${[ - tw`flex w-full cursor-pointer items-center gap-2 rounded font-medium leading-[16px] outline-primary-600 transition hover:transition-none focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-50`, + class=${clsx([ + tw`flex w-full cursor-pointer items-center gap-2 rounded font-medium leading-[16px] transition hover:transition-none focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-50`, + this.icon ? tw`min-h-6 min-w-6` : tw``, { small: this.icon ? tw`min-h-6 p-0` : tw`min-h-6 px-2 py-0`, @@ -89,17 +94,27 @@ export class NavigationButton extends TailwindElement { center: "justify-center", right: "justify-end", }[this.align], - this.active - ? tw`bg-primary-100/80 text-primary-800 shadow-sm shadow-primary-900/20` - : tw`text-neutral-700 hover:bg-primary-50`, - ] - .filter(Boolean) - .join(" ")} + this.active && "shadow-sm", + { + primary: [ + tw`outline-primary-600`, + this.active + ? tw`bg-primary-100/80 text-primary-800 shadow-primary-900/20` + : tw`text-neutral-700 hover:bg-primary-50`, + ], + error: [ + tw`outline-red-600`, + this.active + ? tw`bg-red-100/80 text-red-800 shadow-red-900/20` + : tw`text-red-700 ring-1 ring-red-300 hover:bg-red-50`, + ], + }[this.variant], + ])} ?disabled=${this.disabled} href=${ifDefined(this.href)} aria-label=${ifDefined(this.label)} @click=${this.handleClick} - + > `; diff --git a/frontend/src/components/ui/tab-group/tab-group.ts b/frontend/src/components/ui/tab-group/tab-group.ts index fe34449e9b..5668bef60c 100644 --- a/frontend/src/components/ui/tab-group/tab-group.ts +++ b/frontend/src/components/ui/tab-group/tab-group.ts @@ -12,6 +12,7 @@ import { TailwindElement } from "@/classes/TailwindElement"; import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav"; /** + * @fires btrix-tab-change * @example Usage: * ```ts * @@ -145,5 +146,11 @@ export class TabGroup extends TailwindElement { private onSelectTab(e: CustomEvent) { e.stopPropagation(); this.active = e.detail.panel; + this.dispatchEvent( + new CustomEvent("btrix-tab-change", { + detail: this.active, + bubbles: true, + }), + ); } } diff --git a/frontend/src/context/view-state.ts b/frontend/src/context/view-state.ts new file mode 100644 index 0000000000..210a0b6f0a --- /dev/null +++ b/frontend/src/context/view-state.ts @@ -0,0 +1,7 @@ +import { createContext } from "@lit/context"; + +import { type ViewState } from "@/utils/APIRouter"; + +export type ViewStateContext = ViewState | null; + +export const viewStateContext = createContext("viewState"); diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index 44c9e44030..f27f86b0f0 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -173,6 +173,7 @@ export class APIController implements ReactiveController { async upload( path: string, file: File, + abortSignal?: AbortSignal, ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { const auth = appState.auth; @@ -185,9 +186,12 @@ export class APIController implements ReactiveController { } return new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(AbortReason.UserCancel); + } const xhr = new XMLHttpRequest(); - xhr.open("PUT", `/api/${path}`); + xhr.open("PUT", `/api${path}`); xhr.setRequestHeader("Content-Type", "application/octet-stream"); Object.entries(auth.headers).forEach(([k, v]) => { xhr.setRequestHeader(k, v); @@ -221,6 +225,11 @@ export class APIController implements ReactiveController { xhr.send(file); + abortSignal?.addEventListener("abort", () => { + xhr.abort(); + reject(AbortReason.UserCancel); + }); + this.uploadRequest = xhr; }); } diff --git a/frontend/src/decorators/needLogin.test.ts b/frontend/src/decorators/needLogin.test.ts index 79a1fa940c..66047fa70a 100644 --- a/frontend/src/decorators/needLogin.test.ts +++ b/frontend/src/decorators/needLogin.test.ts @@ -19,6 +19,7 @@ describe("needLogin", () => { } const Element = needLogin( + // @ts-expect-error not stubbing full BtrixElement class TestElement extends LiteElementMock { appState = appState; } as unknown as { diff --git a/frontend/src/features/archived-items/archived-item-list.ts b/frontend/src/features/archived-items/archived-item-list.ts index eab15f1bb2..403e13b796 100644 --- a/frontend/src/features/archived-items/archived-item-list.ts +++ b/frontend/src/features/archived-items/archived-item-list.ts @@ -415,7 +415,7 @@ export class ArchivedItemList extends TailwindElement { { cssCol: "1fr", cell: html` - ${msg("Pages Crawled")} + ${msg("Pages")} `, }, { diff --git a/frontend/src/features/browser-profiles/select-browser-profile.ts b/frontend/src/features/browser-profiles/select-browser-profile.ts index c676861104..99045d2af3 100644 --- a/frontend/src/features/browser-profiles/select-browser-profile.ts +++ b/frontend/src/features/browser-profiles/select-browser-profile.ts @@ -168,7 +168,7 @@ export class SelectBrowserProfile extends BtrixElement { >${msg("This org doesn't have any custom profiles yet.")} { diff --git a/frontend/src/features/collections/collection-metadata-dialog.ts b/frontend/src/features/collections/collection-create-dialog.ts similarity index 78% rename from frontend/src/features/collections/collection-metadata-dialog.ts rename to frontend/src/features/collections/collection-create-dialog.ts index ecc6909e9f..bb400b618a 100644 --- a/frontend/src/features/collections/collection-metadata-dialog.ts +++ b/frontend/src/features/collections/collection-create-dialog.ts @@ -1,7 +1,7 @@ import { localized, msg, str } from "@lit/localize"; import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; -import { html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property, @@ -28,12 +28,9 @@ export type CollectionSavedEvent = CustomEvent<{ /** * @fires btrix-collection-saved CollectionSavedEvent Fires */ -@customElement("btrix-collection-metadata-dialog") +@customElement("btrix-collection-create-dialog") @localized() -export class CollectionMetadataDialog extends BtrixElement { - @property({ type: Object }) - collection?: Collection; - +export class CollectionCreateDialog extends BtrixElement { @property({ type: Boolean }) open = false; @@ -62,10 +59,8 @@ export class CollectionMetadataDialog extends BtrixElement { } render() { - return html` (this.isDialogVisible = true)} @sl-after-hide=${() => (this.isDialogVisible = false)} @@ -98,9 +93,7 @@ export class CollectionMetadataDialog extends BtrixElement { ); form.requestSubmit(submitInput); }} - >${this.collection - ? msg("Save") - : msg("Create Collection")}${msg("Create Collection")} `; @@ -113,7 +106,6 @@ export class CollectionMetadataDialog extends BtrixElement { class="with-max-help-text" name="name" label=${msg("Name")} - value=${this.collection?.name || ""} placeholder=${msg("My Collection")} autocomplete="off" required @@ -121,13 +113,11 @@ export class CollectionMetadataDialog extends BtrixElement { @sl-input=${this.validateNameMax.validate} > - @@ -138,12 +128,10 @@ export class CollectionMetadataDialog extends BtrixElement { ${msg( "Write a short description that summarizes this collection. If the collection is shareable, this will appear next to the collection name.", )} - ${this.collection - ? nothing - : msg( - html`You can add a longer description in the “About” - section after creating the collection.`, - )} + ${msg( + html`You can add a longer description in the “About” section + after creating the collection.`, + )} - - ${when( - !this.collection, - () => html` - - - (this.showPublicWarning = - (e.detail.item.value as CollectionAccess) === - CollectionAccess.Public)} - > - `, - )} + + + + + (this.showPublicWarning = + (e.detail.item.value as CollectionAccess) === + CollectionAccess.Public)} + > + ${when( this.showPublicWarning && this.org, (org) => html` @@ -218,18 +203,11 @@ export class CollectionMetadataDialog extends BtrixElement { const body = JSON.stringify({ name, caption, - access: - this.selectCollectionAccess?.value || - this.collection?.access || - CollectionAccess.Private, + access: this.selectCollectionAccess?.value || CollectionAccess.Private, defaultThumbnailName: DEFAULT_THUMBNAIL, }); - let path = `/orgs/${this.orgId}/collections`; - let method = "POST"; - if (this.collection) { - path = `/orgs/${this.orgId}/collections/${this.collection.id}`; - method = "PATCH"; - } + const path = `/orgs/${this.orgId}/collections`; + const method = "POST"; const data = await this.api.fetch(path, { method, body, @@ -238,14 +216,12 @@ export class CollectionMetadataDialog extends BtrixElement { this.dispatchEvent( new CustomEvent("btrix-collection-saved", { detail: { - id: this.collection?.id || data.id, + id: data.id, }, }) as CollectionSavedEvent, ); this.notify.toast({ - message: this.collection - ? msg(str`"${data.name || name}" metadata updated`) - : msg(str`Created "${data.name || name}" collection`), + message: msg(str`Created "${data.name || name}" collection`), variant: "success", icon: "check2-circle", id: "collection-metadata-status", diff --git a/frontend/src/features/collections/collection-edit-dialog.ts b/frontend/src/features/collections/collection-edit-dialog.ts new file mode 100644 index 0000000000..13350e832e --- /dev/null +++ b/frontend/src/features/collections/collection-edit-dialog.ts @@ -0,0 +1,388 @@ +import { localized, msg, str } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import { type SlRequestCloseEvent } from "@shoelace-style/shoelace"; +import { html, nothing, type PropertyValues } from "lit"; +import { + customElement, + property, + query, + queryAsync, + state, +} from "lit/decorators.js"; +import { type Embed } from "replaywebpage"; + +import { type CollectionSnapshotPreview } from "./collection-snapshot-preview"; +import { type Thumbnail } from "./collection-thumbnail"; +import checkChanged from "./edit-dialog/helpers/check-changed"; +import submitTask from "./edit-dialog/helpers/submit-task"; +import renderPresentation from "./edit-dialog/presentation-section"; +import { type CollectionShareSettings } from "./edit-dialog/sharing-section"; +import { type SelectCollectionPage } from "./select-collection-page"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import { type TabGroupPanel } from "@/components/ui/tab-group/tab-panel"; +import { + type Collection, + type CollectionThumbnailSource, +} from "@/types/collection"; +import { maxLengthValidator, type MaxLengthValidator } from "@/utils/form"; +import { formatRwpTimestamp } from "@/utils/replay"; + +type Tab = "general" | "sharing"; + +export type { Tab as EditDialogTab }; + +export type CollectionSavedEvent = CustomEvent<{ + id: string; +}>; + +export const validateNameMax = maxLengthValidator(50); +export const validateCaptionMax = maxLengthValidator(150); + +/** + * @fires btrix-collection-saved CollectionSavedEvent Fires + */ +@customElement("btrix-collection-edit-dialog") +@localized() +export class CollectionEdit extends BtrixElement { + @property({ type: Object }) + collection?: Collection; + + /** For contexts where we don't have the full collection object already - + * Will cause this to fetch the collection internally, so avoid if there's + * already a collection object available where this is being used. + */ + @property({ type: String }) + collectionId?: string; + + @property({ type: Boolean }) + open = false; + + /** + * If there's an existing RWP instance loaded, pass it into this property; + * otherwise, this dialog will load its own instance. RWP is required for + * fetching thumbnails. + */ + @property({ type: Object }) + replayWebPage?: Embed | null | undefined; + + @property({ type: Boolean }) + replayLoaded = false; + + @state() + isDialogVisible = false; + + @property({ type: String }) + tab: Tab = "general"; + + @state() + errorTab: Tab | null = null; + + @state() + thumbnailIsValid: boolean | null = null; + + @state() + dirty = false; + + // Separating this out so that we can eagerly respond to name changes in dialog title & toasts + @state() + name = this.collection?.name; + + @state() + defaultThumbnailName: `${Thumbnail}` | null = + (this.collection?.defaultThumbnailName as + | `${Thumbnail}` + | null + | undefined) || null; + + @state() + selectedSnapshot: CollectionThumbnailSource | null = + this.collection?.thumbnailSource ?? null; + + @state() + blobIsLoaded = false; + + @query("btrix-dialog") + readonly dialog?: Dialog; + + @queryAsync("#collectionEditForm") + readonly form!: Promise; + + @queryAsync("btrix-collection-share-settings") + readonly shareSettings?: Promise; + + @query("btrix-select-collection-page") + readonly thumbnailSelector?: SelectCollectionPage; + + @query("btrix-collection-snapshot-preview") + public readonly thumbnailPreview?: CollectionSnapshotPreview | null; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("collectionId") && this.collectionId) { + void this.fetchCollection(this.collectionId); + } + if (changedProperties.has("collectionId") && !this.collectionId) { + this.onReset(); + this.collection = undefined; + } + if ( + changedProperties.has("collection") && + changedProperties.get("collection")?.id != this.collection?.id + ) { + this.defaultThumbnailName = + (this.collection?.defaultThumbnailName as `${Thumbnail}` | null) || + null; + this.selectedSnapshot = this.collection?.thumbnailSource ?? null; + } + } + + readonly checkChanged = checkChanged.bind(this); + + private readonly submitTask = new Task(this, { + task: submitTask.bind(this)(), + autoRun: false, + }); + + validate(validator: MaxLengthValidator) { + return (e: CustomEvent) => { + const valid = validator.validate(e); + if (!valid) { + const el = e.target as HTMLElement; + this.errorTab = el.closest("btrix-tab-group-panel")! + .name as Tab; + } else { + this.errorTab = null; + } + }; + } + + private async onSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + + await this.submitTask.run(); + + this.dirty = false; + void this.hideDialog(); + } + + private async hideDialog() { + void this.dialog?.hide(); + } + + private onReset() { + void this.hideDialog(); + void this.thumbnailSelector?.resetFormState(); + this.dirty = false; + this.errorTab = null; + this.blobIsLoaded = false; + this.selectedSnapshot = this.collection?.thumbnailSource ?? null; + this.defaultThumbnailName = + (this.collection?.defaultThumbnailName as + | `${Thumbnail}` + | null + | undefined) || null; + } + + protected firstUpdated(): void { + if (this.open) { + this.isDialogVisible = true; + } + } + + render() { + return html` (this.isDialogVisible = true)} + @sl-after-hide=${() => { + this.isDialogVisible = false; + // Reset the open tab when closing the dialog + this.tab = "general"; + }} + @sl-request-close=${(e: SlRequestCloseEvent) => { + if (e.detail.source === "close-button") { + this.onReset(); + return; + } + // Prevent accidental closes unless data has been saved + // Closing via the close buttons is fine though, cause it resets the form first. + if (this.dirty) e.preventDefault(); + }} + class="h-full [--width:var(--btrix-screen-desktop)]" + > + ${this.collection + ? html` +
{ + void this.checkChanged(); + }} + @sl-input=${() => { + void this.checkChanged(); + }} + @sl-change=${() => { + void this.checkChanged(); + }} + > + ) => { + this.tab = e.detail; + }} + class="part-[content]:pt-4" + > + ${this.renderTab({ + panel: "general", + icon: "easel3-fill", + string: msg("Presentation"), + })} + ${this.renderTab({ + panel: "sharing", + icon: "globe2", + string: msg("Sharing"), + })} + + + ${renderPresentation.bind(this)()} + + + + + + + +
+ ` + : html` +
+ +
+ `} +
+ { + // Using reset method instead of type="reset" fixes + // incorrect getRootNode in Chrome + (await this.form).reset(); + }} + >${this.dirty ? msg("Discard Changes") : msg("Cancel")} + ${this.dirty + ? html`${msg("Unsaved changes.")}` + : nothing} + ${this.errorTab !== null + ? html`${msg("Please review issues with your changes.")}` + : nothing} + { + // Using submit method instead of type="submit" fixes + // incorrect getRootNode in Chrome + const form = await this.form; + const submitInput = form.querySelector( + 'input[type="submit"]', + ); + form.requestSubmit(submitInput); + }} + >${msg("Save")} +
+
+ ${this.renderReplay()}`; + } + + private renderReplay() { + if (this.replayWebPage) return; + if (!this.collection) return; + if (!this.collection.crawlCount) return; + + const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`; + const headers = this.authState?.headers; + const config = JSON.stringify({ headers }); + + return html``; + } + + private renderTab({ + panel, + icon, + string, + }: { + panel: Tab; + icon: string; + string: string; + }) { + return html` + + ${string} + `; + } + + private async fetchCollection(id: string) { + try { + this.collection = await this.getCollection(id); + } catch (e) { + this.notify.toast({ + message: msg("Sorry, couldn't retrieve Collection at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "collection-retrieve-status", + }); + } + } + + private async getCollection(id: string) { + const data = await this.api.fetch( + `/orgs/${this.orgId}/collections/${id}/replay.json`, + ); + + return data; + } +} diff --git a/frontend/src/features/collections/collection-replay-dialog.ts b/frontend/src/features/collections/collection-replay-dialog.ts index 61ae21050d..618381032b 100644 --- a/frontend/src/features/collections/collection-replay-dialog.ts +++ b/frontend/src/features/collections/collection-replay-dialog.ts @@ -10,7 +10,7 @@ import { HomeView, type CollectionSnapshotPreview, } from "./collection-snapshot-preview"; -import type { SelectSnapshotDetail } from "./select-collection-start-page"; +import type { SelectSnapshotDetail } from "./select-collection-page"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; @@ -82,7 +82,7 @@ export class CollectionStartPageDialog extends BtrixElement { this.homeView === HomeView.URL && !this.selectedSnapshot; return html` (this.showContent = true)} @@ -198,7 +198,7 @@ export class CollectionStartPageDialog extends BtrixElement {
html`
- { this.selectedSnapshot = e.detail.item; }} - > - - + > + + ${msg("Update collection thumbnail")} - - -
`, @@ -301,7 +298,8 @@ export class CollectionStartPageDialog extends BtrixElement { homeView === HomeView.URL && useThumbnail === "on" && this.selectedSnapshot && - this.collection?.homeUrlPageId !== this.selectedSnapshot.pageId; + this.collection?.thumbnailSource?.urlPageId !== + this.selectedSnapshot.pageId; // TODO get filename from rwp? const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`; let file: File | undefined; @@ -328,12 +326,22 @@ export class CollectionStartPageDialog extends BtrixElement { if (shouldUpload) { try { - if (!file || !fileName) throw new Error("file or fileName missing"); - await this.api.upload( - `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?filename=${fileName}`, - file, - ); - await this.updateThumbnail({ defaultThumbnailName: null }); + if (!file || !fileName || !this.selectedSnapshot) + throw new Error("file or fileName missing"); + const searchParams = new URLSearchParams({ + filename: fileName, + sourceUrl: this.selectedSnapshot.url, + sourceTs: this.selectedSnapshot.ts, + sourcePageId: this.selectedSnapshot.pageId, + }); + const tasks = [ + this.api.upload( + `/orgs/${this.orgId}/collections/${this.collectionId}/thumbnail?${searchParams.toString()}`, + file, + ), + this.updateThumbnail({ defaultThumbnailName: null }), + ]; + await Promise.all(tasks); this.notify.toast({ message: msg("Home view and collection thumbnail updated."), diff --git a/frontend/src/features/collections/collection-snapshot-preview.ts b/frontend/src/features/collections/collection-snapshot-preview.ts index b335149d37..f21c74c480 100644 --- a/frontend/src/features/collections/collection-snapshot-preview.ts +++ b/frontend/src/features/collections/collection-snapshot-preview.ts @@ -1,10 +1,11 @@ import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; import clsx from "clsx"; -import { html, type PropertyValues } from "lit"; +import { html, nothing, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { isEqual } from "lodash"; -import type { SelectSnapshotDetail } from "./select-collection-start-page"; +import type { SelectSnapshotDetail } from "./select-collection-page"; import { TailwindElement } from "@/classes/TailwindElement"; import { formatRwpTimestamp } from "@/utils/replay"; @@ -15,6 +16,10 @@ export enum HomeView { URL = "url", } +export type BtrixValidateDetails = { + valid: boolean; +}; + /** * Display preview of page snapshot. * @@ -32,45 +37,90 @@ export class CollectionSnapshotPreview extends TailwindElement { @property({ type: String }) view?: HomeView; - @property({ type: Object }) - snapshot?: SelectSnapshotDetail["item"]; + @property({ type: Boolean }) + noSpinner = false; + + @property({ + type: Object, + hasChanged: (a, b) => !isEqual(a, b), + }) + snapshot?: Partial; @query("iframe") private readonly iframe?: HTMLIFrameElement | null; + @query("img#preview") + private readonly previewImg?: HTMLImageElement | null; + @state() private iframeLoaded = false; + // Set up a promise and a helper callback so that we can wait until the iframe is loaded, rather than returning nothing when it's not yet loaded + private iframeLoadComplete!: () => void; + private readonly iframeLoadedPromise = new Promise((res) => { + if (this.iframeLoaded) res(); + this.iframeLoadComplete = res; + }); + public get thumbnailBlob() { - return this.blobTask.taskComplete.finally(() => this.blobTask.value); + return this.blobTask.taskComplete.then(() => this.blobTask.value); } - private readonly blobTask = new Task(this, { - task: async ([collectionId, snapshot, iframeLoaded]) => { - if ( - !collectionId || - !snapshot || - !iframeLoaded || - !this.iframe?.contentWindow - ) { - return; + public readonly blobTask = new Task(this, { + task: async ([collectionId, snapshot], { signal }) => { + try { + console.debug("waiting for iframe to load", { collectionId, snapshot }); + await this.iframeLoadedPromise; + if ( + !collectionId || + !snapshot?.ts || + !snapshot.url || + !this.iframe?.contentWindow + ) { + console.debug( + "exiting early due to missing props", + collectionId, + snapshot, + this.iframe?.contentWindow, + ); + return; + } + + const resp = await this.iframe.contentWindow.fetch( + `/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`, + { signal }, + ); + + if (resp.status === 200) { + this.dispatchEvent( + new CustomEvent("btrix-validate", { + detail: { valid: true }, + }), + ); + return await resp.blob(); + } + + throw new Error(`couldn't get thumbnail`); + } catch (e) { + console.error(e); + if (signal.aborted) return; + this.dispatchEvent( + new CustomEvent("btrix-validate", { + detail: { valid: false }, + }), + ); + throw e; } - - const resp = await this.iframe.contentWindow.fetch( - `/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`, - ); - - if (resp.status === 200) { - return await resp.blob(); - } - - throw new Error(`couldn't get thumbnail`); }, - args: () => [this.collectionId, this.snapshot, this.iframeLoaded] as const, + args: () => [this.collectionId, this.snapshot] as const, }); + @state() + private prevObjUrl?: string; + private readonly objectUrlTask = new Task(this, { task: ([blob]) => { + this.prevObjUrl = this.objectUrlTask.value; if (!blob) return ""; const url = URL.createObjectURL(blob); @@ -95,9 +145,15 @@ export class CollectionSnapshotPreview extends TailwindElement { changedProperties.has("collectionId") || changedProperties.has("snapshot") ) { - if (this.objectUrlTask.value) { - URL.revokeObjectURL(this.objectUrlTask.value); - } + // revoke object urls once the `` element has loaded the next url, to + // prevent flashes + + this.previewImg?.addEventListener("load", () => { + if (this.prevObjUrl) { + URL.revokeObjectURL(this.prevObjUrl); + this.prevObjUrl = undefined; + } + }); } } @@ -110,16 +166,21 @@ export class CollectionSnapshotPreview extends TailwindElement { return this.blobTask.render({ complete: this.renderImage, - pending: this.renderSpinner, + pending: this.renderImage, error: this.renderError, }); } private readonly renderImage = () => { if (!this.snapshot) { + if (this.noSpinner) return; return html` -

- ${msg("Enter a Page URL to preview it")} +

+ + ${msg("Enter a Page URL to preview it.")} +

`; } @@ -127,13 +188,23 @@ export class CollectionSnapshotPreview extends TailwindElement { return html`
- ${this.objectUrlTask.render({ - complete: (value) => - value - ? html`` - : this.renderSpinner(), - pending: () => "pending", - })} + ${this.objectUrlTask.value ? nothing : this.renderSpinner()} +
+ ${this.prevObjUrl + ? html`` + : nothing} + ${this.objectUrlTask.value + ? html`` + : nothing} +
${this.snapshot.url}
@@ -151,6 +222,8 @@ export class CollectionSnapshotPreview extends TailwindElement { src=${this.replaySrc} @load=${() => { this.iframeLoaded = true; + this.iframeLoadComplete(); + console.debug("iframe loaded"); }} > @@ -159,14 +232,19 @@ export class CollectionSnapshotPreview extends TailwindElement { } private readonly renderError = () => html` -

- ${msg("Couldn't load preview. Try another snapshot")} +

+ ${msg("This page doesn’t have a preview. Try another URL or timestamp.")}

`; - private readonly renderSpinner = () => html` -
- -
- `; + private readonly renderSpinner = () => { + if (this.noSpinner) return; + return html` +
+ +
+ `; + }; } diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts index 161479fd95..501759dbbb 100644 --- a/frontend/src/features/collections/collections-grid.ts +++ b/frontend/src/features/collections/collections-grid.ts @@ -1,6 +1,7 @@ import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; import { html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; @@ -25,6 +26,12 @@ export class CollectionsGrid extends BtrixElement { @property({ type: Array }) collections?: PublicCollection[]; + @state() + collectionBeingEdited: string | null = null; + + @property({ type: String }) + collectionRefreshing: string | null = null; + render() { const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; @@ -58,12 +65,15 @@ export class CollectionsGrid extends BtrixElement {
+ ${when( + showActions, + () => + html` { + this.collectionBeingEdited = null; + }} + >`, + )} `; } private readonly renderActions = (collection: PublicCollection) => html`
- - - - - ${msg("Visit Public Collections Gallery")} - - - + + { + this.collectionBeingEdited = collection.id; + }} + > + + +
`; @@ -132,7 +163,7 @@ export class CollectionsGrid extends BtrixElement { return html` ${earliestYear} - ${latestYear !== earliestYear ? html` - ${latestYear} ` : nothing} + ${latestYear !== earliestYear ? html` – ${latestYear} ` : nothing} `; } diff --git a/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts new file mode 100644 index 0000000000..a322b046e4 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/check-changed.ts @@ -0,0 +1,78 @@ +import { isEqual } from "lodash"; + +import { type CollectionEdit } from "../../collection-edit-dialog"; + +import gatherState from "./gather-state"; + +import type { Collection, CollectionUpdate } from "@/types/collection"; + +const checkEqual = ( + collection: Collection, + key: K, + b: CollectionUpdate[K] | null, +) => { + let a = collection[key] as (typeof collection)[K] | null; + // caption is sometimes null when empty, collection update has empty string instead + if (key === "caption") { + a = a || null; + b = b || null; + } + // deeply compare (for objects) + const eq = isEqual(a, b); + return eq; +}; + +type KVPairs = { + [K in keyof T]-?: readonly [K, T[K]]; +}[keyof T][]; + +export default async function checkChanged(this: CollectionEdit) { + try { + const { collectionUpdate, thumbnail, setInitialView } = + await gatherState.bind(this)(); + + const state: CollectionUpdate = { + ...collectionUpdate, + }; + + const pairs = Object.entries(state) as KVPairs; + + // filter out unchanged properties + const updates = pairs.filter( + ([name, value]) => !checkEqual(this.collection!, name, value), + ) as KVPairs< + CollectionUpdate & { + thumbnail: typeof thumbnail; + setInitialView: typeof setInitialView; + } + >; + + const shouldUpload = + thumbnail.selectedSnapshot && + !isEqual(this.collection?.thumbnailSource, thumbnail.selectedSnapshot) && + this.blobIsLoaded; + + if (shouldUpload) { + updates.push(["thumbnail", thumbnail]); + } + if (setInitialView) { + if ( + this.collection && + thumbnail.selectedSnapshot && + this.collection.homeUrlPageId !== thumbnail.selectedSnapshot.urlPageId + ) { + updates.push(["setInitialView", true]); + } + } + if (updates.length > 0) { + this.dirty = true; + } else { + this.dirty = false; + } + + return updates; + } catch (e) { + console.error(e); + this.dirty = true; + } +} diff --git a/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts new file mode 100644 index 0000000000..971bc2a7f6 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/gather-state.ts @@ -0,0 +1,58 @@ +import { getFormControls, serialize } from "@shoelace-style/shoelace"; + +import { + type CollectionEdit, + type EditDialogTab, +} from "../../collection-edit-dialog"; + +import type { TabGroupPanel } from "@/components/ui/tab-group/tab-panel"; +import { + collectionUpdateSchema, + type CollectionUpdate, +} from "@/types/collection"; + +export default async function gatherState(this: CollectionEdit) { + const form = await this.form; + + const elements = getFormControls(form); + const invalidElement = elements.find( + (el) => !(el as HTMLInputElement).checkValidity(), + ); + if (invalidElement) { + this.errorTab = invalidElement.closest( + "btrix-tab-group-panel", + )!.name as EditDialogTab; + (invalidElement as HTMLElement).focus(); + throw new Error("invalid_data"); + } else { + this.errorTab = null; + } + + const { access, allowPublicDownload } = (await this.shareSettings) ?? {}; + + const formData = serialize(form) as CollectionUpdate & { + setInitialView: boolean; + }; + + const selectedSnapshot = this.selectedSnapshot; + + if (this.defaultThumbnailName == null && !selectedSnapshot) { + formData.thumbnailSource = null; + } + + const { setInitialView } = formData; + const data: CollectionUpdate = { + ...formData, + access, + defaultThumbnailName: this.defaultThumbnailName, + allowPublicDownload, + }; + + return { + collectionUpdate: collectionUpdateSchema.parse(data), + thumbnail: { + selectedSnapshot, + }, + setInitialView, + }; +} diff --git a/frontend/src/features/collections/edit-dialog/helpers/snapshots.ts b/frontend/src/features/collections/edit-dialog/helpers/snapshots.ts new file mode 100644 index 0000000000..fea89e4748 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/snapshots.ts @@ -0,0 +1,26 @@ +import { type SnapshotItem } from "../../select-collection-page"; + +import { type CollectionThumbnailSource } from "@/types/collection"; + +export function sourceToSnapshot( + source: CollectionThumbnailSource | null, +): SnapshotItem | null { + if (source == null) return null; + return { + pageId: source.urlPageId, + status: 200, + ts: source.urlTs, + url: source.url, + }; +} + +export function snapshotToSource( + source: SnapshotItem | null, +): CollectionThumbnailSource | null { + if (source == null) return null; + return { + urlPageId: source.pageId, + urlTs: source.ts, + url: source.url, + }; +} diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts new file mode 100644 index 0000000000..5504aed382 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -0,0 +1,131 @@ +import { msg, str } from "@lit/localize"; +import { type TaskFunction } from "@lit/task"; + +import { type CollectionEdit } from "../../collection-edit-dialog"; + +import { + type CollectionThumbnailSource, + type CollectionUpdate, +} from "@/types/collection"; +import { isApiError } from "@/utils/api"; + +export default function submitTask( + this: CollectionEdit, +): TaskFunction { + return async (_, { signal }) => { + if (!this.collection) throw new Error("Collection is undefined"); + try { + const updates = await this.checkChanged(); + if (!updates) throw new Error("invalid_data"); + const updateObject = Object.fromEntries(updates) as CollectionUpdate & { + thumbnail?: { + selectedSnapshot: CollectionThumbnailSource; + }; + setInitialView?: boolean; + }; + const { + thumbnail: { selectedSnapshot } = {}, + setInitialView, + ...rest + } = updateObject; + const tasks = []; + + // TODO get filename from rwp? + const fileName = `page-thumbnail_${selectedSnapshot?.urlPageId}.jpeg`; + let file: File | undefined; + + if (selectedSnapshot) { + const blob = await this.thumbnailPreview?.thumbnailBlob.catch(() => { + throw new Error("invalid_data"); + }); + if (blob) { + file = new File([blob], fileName, { + type: blob.type, + }); + } + if (!file) throw new Error("invalid_data"); + const searchParams = new URLSearchParams({ + filename: fileName, + sourceUrl: selectedSnapshot.url, + sourceTs: selectedSnapshot.urlTs, + sourcePageId: selectedSnapshot.urlPageId, + }); + tasks.push( + this.api.upload( + `/orgs/${this.orgId}/collections/${this.collection.id}/thumbnail?${searchParams.toString()}`, + file, + signal, + ), + ); + rest.defaultThumbnailName = null; + } + + if (setInitialView) { + tasks.push( + this.api.fetch( + `/orgs/${this.orgId}/collections/${this.collection.id}/home-url`, + { + method: "POST", + body: JSON.stringify({ + pageId: this.selectedSnapshot?.urlPageId, + }), + }, + ), + ); + } + + if (Object.keys(rest).length) { + tasks.push( + await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/collections/${this.collection.id}`, + { + method: "PATCH", + body: JSON.stringify(rest), + signal, + }, + ), + ); + } + + await Promise.all(tasks); + + this.dispatchEvent( + new CustomEvent<{ + id: string; + }>("btrix-collection-saved", { + detail: { + id: this.collection.id, + }, + bubbles: true, + composed: true, + }), + ); + this.dispatchEvent(new CustomEvent("btrix-change")); + this.notify.toast({ + message: msg( + str`Updated collection “${this.name || this.collection.name}”`, + ), + variant: "success", + icon: "check2-circle", + id: "collection-metadata-status", + }); + // void this.hideDialog(); + } catch (e) { + let message = isApiError(e) && e.message; + if (message === "collection_name_taken") { + message = msg("This name is already taken."); + } + if (message === "invalid_data") { + message = msg("Please review issues with your changes."); + } + console.error(e); + this.notify.toast({ + message: message || msg("Something unexpected went wrong"), + variant: "danger", + icon: "exclamation-octagon", + id: "collection-metadata-status", + }); + throw e; + } + }; +} diff --git a/frontend/src/features/collections/edit-dialog/presentation-section.ts b/frontend/src/features/collections/edit-dialog/presentation-section.ts new file mode 100644 index 0000000000..18da48d407 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/presentation-section.ts @@ -0,0 +1,330 @@ +import { msg } from "@lit/localize"; +import { TaskStatus } from "@lit/task"; +import { type SlInput } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { html, nothing } from "lit"; +import { when } from "lit/directives/when.js"; +import { isEqual } from "lodash"; +import queryString from "query-string"; + +import { + validateCaptionMax, + validateNameMax, + type CollectionEdit, +} from "../collection-edit-dialog"; +import { type BtrixValidateDetails } from "../collection-snapshot-preview"; +import { + CollectionThumbnail, + DEFAULT_THUMBNAIL_VARIANT, + Thumbnail, +} from "../collection-thumbnail"; +import { type SelectSnapshotDetail } from "../select-collection-page"; + +import { snapshotToSource, sourceToSnapshot } from "./helpers/snapshots"; + +import type { PublicCollection } from "@/types/collection"; +import { tw } from "@/utils/tailwind"; + +export default function renderPresentation(this: CollectionEdit) { + if (!this.collection) return; + return html` { + this.validate(validateNameMax)(e); + this.name = (e.target as SlInput).value; + }} + > + + + + ${msg("Summary")} + + + ${msg( + "Write a short description that summarizes this collection. If the collection is public, this description will be visible next to the collection name.", + )} + + + + + +
${renderThumbnails.bind(this)()}
+
+ ) => { + if (!e.detail.item) return; + const newSnapshot = snapshotToSource(e.detail.item); + if (!isEqual(newSnapshot, this.selectedSnapshot)) { + this.thumbnailIsValid = null; + this.selectedSnapshot = newSnapshot; + } + + void this.checkChanged(); + }} + > + ${this.thumbnailIsValid === false + ? html` + + ` + : this.thumbnailPreview?.blobTask.status === TaskStatus.PENDING && + !this.blobIsLoaded + ? html`` + : nothing} + + + ${msg("Set initial view to this page")} + +
`; +} + +function renderThumbnails(this: CollectionEdit) { + let selectedImgSrc: string | null = DEFAULT_THUMBNAIL_VARIANT.path; + + if (this.defaultThumbnailName) { + const variant = Object.entries(CollectionThumbnail.Variants).find( + ([name]) => name === this.defaultThumbnailName, + ); + + if (variant) { + selectedImgSrc = variant[1].path; + } + } else if (this.collection?.thumbnail) { + selectedImgSrc = this.collection.thumbnail.path; + } else { + selectedImgSrc = null; + } + + const thumbnail = ( + thumbnail?: Thumbnail | NonNullable, + ) => { + let name: Thumbnail | null = null; + let path = ""; + + if (!thumbnail) + return html` `; + + if (typeof thumbnail === "string") { + // we know that the thumbnail here is one of the placeholders + name = thumbnail; + path = CollectionThumbnail.Variants[name].path; + } else { + path = thumbnail.path; + } + + if (!path) { + console.error("no path for thumbnail:", thumbnail); + return; + } + + const isSelected = path === selectedImgSrc; + + return html` + + + + `; + }; + + return html` +
+ + +
+
+
+ ${msg("Page Thumbnail")} +
+ ${renderPageThumbnail.bind(this)( + this.defaultThumbnailName == null + ? this.collection?.thumbnail?.path + : null, + )} +
+ ${msg("Placeholder")} +
+ ${thumbnail(Thumbnail.Cyan)} ${thumbnail(Thumbnail.Green)} + ${thumbnail(Thumbnail.Yellow)} ${thumbnail(Thumbnail.Orange)} +
+
+
+ `; +} + +function renderPageThumbnail( + this: CollectionEdit, + initialPath?: string | null, +) { + const replaySource = `/api/orgs/${this.orgId}/collections/${this.collection!.id}/replay.json`; + // TODO Get query from replay-web-page embed + const query = queryString.stringify({ + source: replaySource, + customColl: this.collection!.id, + embed: "default", + noCache: 1, + noSandbox: 1, + }); + + const isSelected = this.defaultThumbnailName == null; + + this.thumbnailPreview?.thumbnailBlob + .then((value) => { + this.blobIsLoaded = !!value; + }) + .catch(() => { + this.blobIsLoaded = false; + }); + + const enabled = + (!!this.selectedSnapshot && this.blobIsLoaded) || !!initialPath; + + return html` + + `; +} diff --git a/frontend/src/features/collections/edit-dialog/sharing-section.ts b/frontend/src/features/collections/edit-dialog/sharing-section.ts new file mode 100644 index 0000000000..1de86ab733 --- /dev/null +++ b/frontend/src/features/collections/edit-dialog/sharing-section.ts @@ -0,0 +1,199 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import type { + SlChangeEvent, + SlSelectEvent, + SlSwitch, +} from "@shoelace-style/shoelace"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { collectionShareLink } from "../helpers/share-link"; +import { type SelectCollectionAccess } from "../select-collection-access"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { viewStateContext, type ViewStateContext } from "@/context/view-state"; +import { CollectionAccess, type Collection } from "@/types/collection"; + +@customElement("btrix-collection-share-settings") +@localized() +export class CollectionShareSettings extends BtrixElement { + @property({ type: Object }) + collection?: Collection; + + @consume({ context: viewStateContext }) + viewState?: ViewStateContext; + + @property({ type: String }) + public access = this.collection?.access; + @property({ type: Boolean }) + public allowPublicDownload = this.collection?.allowPublicDownload; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("collection")) { + this.access = this.collection?.access; + this.allowPublicDownload = this.collection?.allowPublicDownload; + } + } + + private get shareLink() { + return collectionShareLink( + this.collection, + this.orgSlugState, + this.viewState?.params.slug || "", + ); + } + + private get publicReplaySrc() { + if (!this.collection) return; + return new URL( + `/api/orgs/${this.collection.oid}/collections/${this.collection.id}/public/replay.json`, + window.location.href, + ).href; + } + render() { + return html` +
+ { + this.access = (e.target as SelectCollectionAccess).value; + this.dispatchEvent( + new CustomEvent("btrix-change", { + bubbles: true, + }), + ); + }} + > + ${when( + this.org && + !this.org.enablePublicProfile && + this.collection?.access === CollectionAccess.Public, + () => html` + + ${msg( + "The org profile page isn't public yet. To make the org profile and this collection visible to the public, update profile visibility in org settings.", + )} + + `, + )} +
+ ${when( + this.collection?.access != CollectionAccess.Private, + () => html`
${this.renderShareLink()}
`, + )} + +
+
+ ${msg("Downloads")} + + + +
+
+ { + this.allowPublicDownload = (e.target as SlSwitch).checked; + }} + >${msg("Show download button")} +
+
+ `; + } + 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` + ${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/features/collections/helpers/share-link.ts b/frontend/src/features/collections/helpers/share-link.ts new file mode 100644 index 0000000000..2f6dceb8a0 --- /dev/null +++ b/frontend/src/features/collections/helpers/share-link.ts @@ -0,0 +1,20 @@ +import { RouteNamespace } from "@/routes"; +import { CollectionAccess, type Collection } from "@/types/collection"; + +export function collectionShareLink( + collection: + | (Pick & Partial>) + | undefined, + privateSlug: string | null, + publicSlug: string | null, +) { + const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + if (collection) { + return `${baseUrl}/${ + collection.access === CollectionAccess.Private + ? `${RouteNamespace.PrivateOrgs}/${privateSlug}/collections/view/${collection.id}` + : `${RouteNamespace.PublicOrgs}/${publicSlug}/collections/${collection.slug}` + }`; + } + return ""; +} diff --git a/frontend/src/features/collections/index.ts b/frontend/src/features/collections/index.ts index 3480cdd694..1163924aaa 100644 --- a/frontend/src/features/collections/index.ts +++ b/frontend/src/features/collections/index.ts @@ -1,10 +1,12 @@ import("./collections-add"); import("./collections-grid"); import("./collection-items-dialog"); -import("./collection-metadata-dialog"); +import("./collection-edit-dialog"); +import("./collection-create-dialog"); import("./collection-replay-dialog"); import("./collection-workflow-list"); import("./select-collection-access"); -import("./select-collection-start-page"); +import("./select-collection-page"); import("./share-collection"); import("./collection-thumbnail"); +import("./edit-dialog/sharing-section"); diff --git a/frontend/src/features/collections/select-collection-start-page.ts b/frontend/src/features/collections/select-collection-page.ts similarity index 72% rename from frontend/src/features/collections/select-collection-start-page.ts rename to frontend/src/features/collections/select-collection-page.ts index eb01881e5d..6ff78849d1 100644 --- a/frontend/src/features/collections/select-collection-start-page.ts +++ b/frontend/src/features/collections/select-collection-page.ts @@ -9,6 +9,7 @@ import clsx from "clsx"; import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; +import { isEqual } from "lodash"; import debounce from "lodash/fp/debounce"; import filter from "lodash/fp/filter"; import flow from "lodash/fp/flow"; @@ -34,7 +35,7 @@ type Page = { snapshots: Snapshot[]; }; -type SnapshotItem = Snapshot & { url: string }; +export type SnapshotItem = Snapshot & { url: string }; export type SelectSnapshotDetail = { item: SnapshotItem | null; @@ -52,21 +53,24 @@ const sortByTs = flow( * @fires btrix-select */ @localized() -@customElement("btrix-select-collection-start-page") -export class SelectCollectionStartPage extends BtrixElement { +@customElement("btrix-select-collection-page") +export class SelectCollectionPage extends BtrixElement { @property({ type: String }) collectionId?: string; @property({ type: Object }) collection?: Collection; + @property({ type: String }) + mode: "homepage" | "thumbnail" = "homepage"; + @state() private searchQuery = ""; @state() private selectedPage?: Page; - @state() + @property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) }) public selectedSnapshot?: Snapshot; @state() @@ -76,7 +80,22 @@ export class SelectCollectionStartPage extends BtrixElement { private readonly combobox?: Combobox | null; @query("#pageUrlInput") - private readonly input?: SlInput | null; + readonly input?: SlInput | null; + + // not actually a nodejs timeout, but since node types are install this is what typescript likes + timer?: NodeJS.Timeout; + + private get url() { + return this.mode === "homepage" + ? this.collection?.homeUrl + : this.collection?.thumbnailSource?.url; + } + + private get ts() { + return this.mode === "homepage" + ? this.collection?.homeUrlTs + : this.collection?.thumbnailSource?.urlTs; + } public get page() { return this.selectedPage; @@ -92,6 +111,11 @@ export class SelectCollectionStartPage extends BtrixElement { } } + public async resetFormState() { + if (!this.collection) return; + await this.initSelection(this.collection); + } + updated(changedProperties: PropertyValues) { if (changedProperties.has("selectedSnapshot")) { this.dispatchEvent( @@ -110,13 +134,13 @@ export class SelectCollectionStartPage extends BtrixElement { } private async initSelection(collection: Collection) { - if (!collection.homeUrl && collection.pageCount !== 1) { + if (!this.url && collection.pageCount !== 1) { return; } const pageUrls = await this.getPageUrls({ id: collection.id, - urlPrefix: collection.homeUrl || "", + urlPrefix: this.url || "", pageSize: 1, }); @@ -127,12 +151,12 @@ export class SelectCollectionStartPage extends BtrixElement { const startPage = pageUrls.items[0]; if (this.input) { - this.input.value = startPage.url; + this.input.value = this.url ?? startPage.url; } this.selectedPage = this.formatPage(startPage); - const homeTs = collection.homeUrlTs; + const homeTs = this.ts; this.selectedSnapshot = homeTs ? this.selectedPage.snapshots.find(({ ts }) => ts === homeTs) @@ -177,6 +201,7 @@ export class SelectCollectionStartPage extends BtrixElement { value=${this.selectedSnapshot?.pageId || ""} ?required=${this.selectedPage && !this.selectedSnapshot} ?disabled=${!this.selectedPage} + size=${this.mode === "thumbnail" ? "small" : "medium"} hoist @sl-change=${async (e: SlChangeEvent) => { const { value } = e.currentTarget as SlSelect; @@ -231,7 +256,16 @@ export class SelectCollectionStartPage extends BtrixElement { return html` { - this.combobox?.hide(); + // Because there are situations where the input might be blurred and + // then immediate refocused (e.g. clicking on the thumbnail preview in + // the collection settings dialog), a delay here prevents issues from + // the order of events being wrong — for some reason sometimes the + // blur event occurs after the focus event. This also prevents the + // combobox from disappearing and then appearing again, instead it + // just stays open. + this.timer = setTimeout(() => { + this.combobox?.hide(); + }, 150); }} > 1} - @sl-focus=${() => { + ?disabled=${!this.collection?.pageCount} + size=${this.mode === "thumbnail" ? "small" : "medium"} + autocomplete="off" + @sl-focus=${async () => { + if (this.timer) clearTimeout(this.timer); this.resetInputValidity(); this.combobox?.show(); }} @@ -256,16 +294,18 @@ export class SelectCollectionStartPage extends BtrixElement { @sl-blur=${this.pageUrlOnBlur} >
- - - + + + + +
${this.renderSearchResults()} @@ -317,44 +357,52 @@ export class SelectCollectionStartPage extends BtrixElement { private renderSearchResults() { return this.searchResults.render({ - pending: () => html` + pending: () => + this.renderItems( + // Render previous value so that dropdown doesn't shift while typing + this.searchResults.value, + ), + complete: this.renderItems, + }); + } + + private readonly renderItems = ( + results: SelectCollectionPage["searchResults"]["value"], + ) => { + if (!results) return; + + const { items } = results; + + if (!items.length) { + return html` - + ${msg("No matching page found.")} - `, - complete: ({ items }) => { - if (!items.length) { - return html` - - ${msg("No matching page found.")} - - `; - } + `; + } + return html` + ${items.map((item: Page) => { return html` - ${items.map((item: Page) => { - return html` - { - if (this.input) { - this.input.value = item.url; - } - - this.selectedPage = this.formatPage(item); - - this.combobox?.hide(); - - this.selectedSnapshot = this.selectedPage.snapshots[0]; - }} - >${item.url} - - `; - })} + { + if (this.input) { + this.input.value = item.url; + } + + this.selectedPage = this.formatPage(item); + + this.combobox?.hide(); + + this.selectedSnapshot = this.selectedPage.snapshots[0]; + }} + >${item.url} + `; - }, - }); - } + })} + `; + }; private readonly onSearchInput = debounce(400)(() => { const value = this.input?.value; diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts index 2d1ec3d55e..3ee177eb4a 100644 --- a/frontend/src/features/collections/share-collection.ts +++ b/frontend/src/features/collections/share-collection.ts @@ -1,26 +1,12 @@ import { localized, msg, str } from "@lit/localize"; -import type { - SlChangeEvent, - SlSelectEvent, - SlSwitch, - SlTabGroup, -} from "@shoelace-style/shoelace"; import { html, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; +import { customElement, property, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import { - CollectionThumbnail, - DEFAULT_THUMBNAIL_VARIANT, - Thumbnail, -} from "./collection-thumbnail"; -import { SelectCollectionAccess } from "./select-collection-access"; +import { collectionShareLink } from "./helpers/share-link"; import { BtrixElement } from "@/classes/BtrixElement"; import { ClipboardController } from "@/controllers/clipboard"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/collections/alerts"; import { AnalyticsTrackEvent } from "@/trackEvents"; import { CollectionAccess, @@ -29,11 +15,6 @@ import { } from "@/types/collection"; import { track } from "@/utils/analytics"; -enum Tab { - Link = "link", - Embed = "embed", -} - /** * @fires btrix-change */ @@ -47,26 +28,22 @@ export class ShareCollection extends BtrixElement { collectionId = ""; @property({ type: Object }) - collection?: Partial; + collection?: Collection | PublicCollection; + + @property({ type: String }) + context: "private" | "public" = "public"; @state() private showDialog = false; - @query("sl-tab-group") - private readonly tabGroup?: SlTabGroup | null; - 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.orgSlugState}/collections/view/${this.collectionId}` - : `${RouteNamespace.PublicOrgs}/${this.orgSlug}/collections/${this.collection.slug}` - }`; - } - return ""; + return collectionShareLink( + this.collection, + this.orgSlugState, + this.orgSlug, + ); } private get publicReplaySrc() { @@ -85,158 +62,72 @@ export class ShareCollection extends BtrixElement { } private renderButton() { - if (!this.collection) { - return html` - - `; - } + if (!this.collection) return; - if (this.collection.access === CollectionAccess.Private) { - return html` - (this.showDialog = true)} - > - - ${msg("Share")} - - `; - } + if (this.collection.access === CollectionAccess.Private) return; return html` - - - + this.shareLink} + content=${msg("Copy Link")} + @click=${() => { + void this.clipboardController.copy(this.shareLink); + + if ( + this.collection && + this.collection.access === CollectionAccess.Public + ) { + track(AnalyticsTrackEvent.CopyShareCollectionLink, { + org_slug: this.orgSlug, + collection_slug: this.collection.slug, + logged_in: !!this.authState, + }); + } + }} + > + + { - void this.clipboardController.copy(this.shareLink); - - if (this.collection?.access === CollectionAccess.Public) { - track(AnalyticsTrackEvent.CopyShareCollectionLink, { - org_slug: this.orgSlug, - collection_slug: this.collection.slug, - logged_in: !!this.authState, - }); - } + this.showDialog = true; }} > - - - ${msg("Copy Link")} - + - - - - - { - this.tabGroup?.show(Tab.Embed); - this.showDialog = true; - }} - > - - ${msg("View Embed Code")} - - ${when( - this.authState && !this.navigate.isPublicPage, - () => html` - + this.context === "public" && + collection.access === CollectionAccess.Public && + collection.allowPublicDownload + ? html` + - ${this.collection?.access === CollectionAccess.Unlisted - ? html` - - ${msg("Visit Unlisted Page")} - ` - : html` - - ${msg("Visit Public Page")} - `} - - ${this.appState.isCrawler - ? html` - - { - this.showDialog = true; - }} - > - - ${msg("Link Settings")} - - ` - : nothing} - `, - )} - ${when(this.orgSlug && this.collection, (collection) => - collection.access === CollectionAccess.Public && - collection.allowPublicDownload - ? html` - { - track(AnalyticsTrackEvent.DownloadPublicCollection, { - org_slug: this.orgSlug, - collection_slug: this.collection?.slug, - }); - }} - > - - ${msg("Download Collection")} - ${when( - this.collection, - (collection) => html` - ${this.localize.bytes( - collection.totalSize || 0, - )} - `, - )} - - ` - : nothing, - )} - - - + { + track(AnalyticsTrackEvent.DownloadPublicCollection, { + org_slug: this.orgSlug, + collection_slug: this.collection?.slug, + }); + }} + > + + + ` + : nothing, + )} + `; } private renderDialog() { - const showSettings = !this.navigate.isPublicPage && this.authState; - return html` { this.showDialog = false; }} - @sl-after-hide=${() => { - this.tabGroup?.show(Tab.Link); - }} - class="[--width:40rem] [--body-spacing:0]" + class="[--body-spacing:0] [--width:40rem]" > - - ${showSettings ? msg("Link Settings") : msg("Link")} - ${msg("Embed")} - - -
- ${when( - showSettings && this.collection, - this.renderSettings, - this.renderShareLink, - )} -
-
- - -
${this.renderEmbedCode()}
-
-
+
+ ${this.renderShareLink()} +
+ ${this.renderEmbedCode()} +
(this.showDialog = false)}> @@ -279,139 +152,6 @@ export class ShareCollection extends BtrixElement { `; } - private readonly renderSettings = (collection: Partial) => { - return html` -
- { - void this.updateVisibility( - (e.target as SelectCollectionAccess).value, - ); - }} - > - ${when( - this.org && - !this.org.enablePublicProfile && - this.collection?.access === CollectionAccess.Public, - () => html` - - ${alerts.orgNotPublicWarning} - - `, - )} -
- ${when( - this.collection?.access != CollectionAccess.Private, - () => html`
${this.renderShareLink()}
`, - )} -
-
- ${msg("Thumbnail")} - - - -
- ${this.renderThumbnails()} -
-
-
- ${msg("Downloads")} - - - -
-
- { - void this.updateAllowDownload((e.target as SlSwitch).checked); - }} - >${msg("Show download button")} -
-
- `; - }; - - private renderThumbnails() { - let selectedImgSrc = DEFAULT_THUMBNAIL_VARIANT.path; - - if (this.collection?.defaultThumbnailName) { - const { defaultThumbnailName } = this.collection; - const variant = Object.entries(CollectionThumbnail.Variants).find( - ([name]) => name === defaultThumbnailName, - ); - - if (variant) { - selectedImgSrc = variant[1].path; - } - } else if (this.collection?.thumbnail) { - selectedImgSrc = this.collection.thumbnail.path; - } - - const thumbnail = ( - thumbnail: Thumbnail | NonNullable, - ) => { - let name: Thumbnail | null = null; - let path = ""; - - if (Object.values(Thumbnail).some((t) => t === thumbnail)) { - name = thumbnail as Thumbnail; - path = CollectionThumbnail.Variants[name].path; - } else { - path = (thumbnail as NonNullable).path; - } - - if (!path) { - console.debug("no path for thumbnail:", thumbnail); - return; - } - - const isSelected = path === selectedImgSrc; - - return html` - - - - `; - }; - - return html` -
- ${when(this.collection?.thumbnail, (t) => thumbnail(t))} - ${thumbnail(Thumbnail.Cyan)} ${thumbnail(Thumbnail.Green)} - ${thumbnail(Thumbnail.Yellow)} ${thumbnail(Thumbnail.Orange)} -
- `; - } - private readonly renderShareLink = () => { return html`
@@ -499,142 +239,4 @@ export class ShareCollection extends BtrixElement {

`; }; - - private async updateVisibility(access: CollectionAccess) { - const prevValue = this.collection?.access; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, access }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ access }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-visibility-update-status", - message: msg("Collection visibility updated."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { ...this.collection, access: prevValue }; - } - - this.notify.toast({ - id: "collection-visibility-update-status", - message: msg("Sorry, couldn't update visibility at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } - - async updateThumbnail({ - defaultThumbnailName, - }: { - defaultThumbnailName: Thumbnail | null; - }) { - const prevValue = this.collection?.defaultThumbnailName; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, defaultThumbnailName }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ defaultThumbnailName }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-thumbnail-update-status", - message: msg("Thumbnail updated."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { - ...this.collection, - defaultThumbnailName: prevValue, - }; - } - - this.notify.toast({ - id: "collection-thumbnail-update-status", - message: msg("Sorry, couldn't update thumbnail at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } - - async updateAllowDownload(allowPublicDownload: boolean) { - const prevValue = this.collection?.allowPublicDownload; - - // Optimistic update - if (this.collection) { - this.collection = { ...this.collection, allowPublicDownload }; - } - - try { - await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/collections/${this.collectionId}`, - { - method: "PATCH", - body: JSON.stringify({ allowPublicDownload }), - }, - ); - - this.dispatchEvent(new CustomEvent("btrix-change")); - - this.notify.toast({ - id: "collection-allow-public-download-update-status", - message: allowPublicDownload - ? msg("Download button enabled.") - : msg("Download button hidden."), - variant: "success", - icon: "check2-circle", - }); - } catch (err) { - console.debug(err); - - // Revert optimistic update - if (this.collection && prevValue !== undefined) { - this.collection = { - ...this.collection, - allowPublicDownload: prevValue, - }; - } - - this.notify.toast({ - id: "collection-allow-public-download-update-status", - message: msg("Sorry, couldn't update download button at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } - } } diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 631e8e34ea..25a3b77028 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -214,7 +214,7 @@ export class WorkflowEditor extends BtrixElement { private progressState?: ProgressState; @state() - private defaults: WorkflowDefaults = appDefaults; + private orgDefaults: WorkflowDefaults = appDefaults; @state() private formState = getDefaultFormState(); @@ -304,7 +304,9 @@ export class WorkflowEditor extends BtrixElement { connectedCallback(): void { this.initializeEditor(); super.connectedCallback(); - void this.fetchServerDefaults(); + + void this.fetchOrgDefaults(); + void this.fetchTags(); this.addEventListener( "btrix-intersect", @@ -350,15 +352,6 @@ export class WorkflowEditor extends BtrixElement { if (this.progressState?.activeTab !== STEPS[0]) { void this.scrollToActivePanel(); } - - if (this.orgId) { - void this.fetchTags(); - void this.fetchOrgQuotaDefaults(); - } - } - - private async fetchServerDefaults() { - this.defaults = await getServerDefaults(); } private initializeEditor() { @@ -1104,12 +1097,12 @@ https://archiveweb.page/images/${"logo.svg"}`} value=${this.formState.pageLimit || ""} min=${minPages} max=${ifDefined( - this.defaults.maxPagesPerCrawl && - this.defaults.maxPagesPerCrawl < Infinity - ? this.defaults.maxPagesPerCrawl + this.orgDefaults.maxPagesPerCrawl && + this.orgDefaults.maxPagesPerCrawl < Infinity + ? this.orgDefaults.maxPagesPerCrawl : undefined, )} - placeholder=${defaultLabel(this.defaults.maxPagesPerCrawl)} + placeholder=${defaultLabel(this.orgDefaults.maxPagesPerCrawl)} @sl-input=${onInputMinMax} > ${msg("pages")} @@ -1152,7 +1145,7 @@ https://archiveweb.page/images/${"logo.svg"}`} type="number" inputmode="numeric" label=${msg("Page Load Timeout")} - placeholder=${defaultLabel(this.defaults.pageLoadTimeoutSeconds)} + placeholder=${defaultLabel(this.orgDefaults.pageLoadTimeoutSeconds)} value=${ifDefined(this.formState.pageLoadTimeoutSeconds ?? undefined)} min="0" @sl-input=${onInputMinMax} @@ -1181,7 +1174,7 @@ https://archiveweb.page/images/${"logo.svg"}`} type="number" inputmode="numeric" label=${msg("Behavior Timeout")} - placeholder=${defaultLabel(this.defaults.behaviorTimeoutSeconds)} + placeholder=${defaultLabel(this.orgDefaults.behaviorTimeoutSeconds)} value=${ifDefined(this.formState.behaviorTimeoutSeconds ?? undefined)} min="0" @sl-input=${onInputMinMax} @@ -1195,7 +1188,7 @@ https://archiveweb.page/images/${"logo.svg"}`} name="autoscrollBehavior" ?checked=${this.formState.autoscrollBehavior} > - ${msg("Auto-scroll behavior")} + ${msg("Autoscroll behavior")} `, )} ${this.renderHelpTextCol( @@ -1278,7 +1271,7 @@ https://archiveweb.page/images/${"logo.svg"}`} > ${when(this.appState.settings?.numBrowsers, (numBrowsers) => map( - range(this.defaults.maxScale), + range(this.orgDefaults.maxScale), (i: number) => html` ${(i + 1) * numBrowsers}(`/orgs/${this.orgId}`); - const orgDefaults = { - ...this.defaults, + const [serverDefaults, { quotas }] = await Promise.all([ + getServerDefaults(), + this.api.fetch<{ + quotas: { maxPagesPerCrawl?: number }; + }>(`/orgs/${this.orgId}`), + ]); + + const defaults = { + ...this.orgDefaults, + ...serverDefaults, }; - if (data.quotas.maxPagesPerCrawl && data.quotas.maxPagesPerCrawl > 0) { - orgDefaults.maxPagesPerCrawl = data.quotas.maxPagesPerCrawl; + + if (defaults.maxPagesPerCrawl && quotas.maxPagesPerCrawl) { + defaults.maxPagesPerCrawl = Math.min( + defaults.maxPagesPerCrawl, + quotas.maxPagesPerCrawl, + ); } - this.defaults = orgDefaults; + + this.orgDefaults = defaults; } catch (e) { console.debug(e); } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index de489f9cab..83513acbae 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,5 +1,6 @@ import "./utils/polyfills"; +import { provide } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import type { SlDialog, @@ -22,6 +23,7 @@ import "./assets/fonts/Inter/inter.css"; import "./assets/fonts/Recursive/recursive.css"; import "./styles.css"; +import { viewStateContext } from "./context/view-state"; import { OrgTab, RouteNamespace, ROUTES } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; import { pageView, type AnalyticsTrackProps } from "./utils/analytics"; @@ -98,6 +100,7 @@ export class App extends BtrixElement { @state() private translationReady = false; + @provide({ context: viewStateContext }) @state() private viewState!: ViewState; @@ -558,7 +561,6 @@ export class App extends BtrixElement { @click=${this.navigate.link} >${msg("Running Crawls")} -
` : nothing} @@ -904,57 +906,6 @@ export class App extends BtrixElement { >`; } - private renderFindCrawl() { - return html` - { - (e.target as HTMLElement).querySelector("sl-input")?.focus(); - }} - @sl-after-hide=${(e: Event) => { - (e.target as HTMLElement).querySelector("sl-input")!.value = ""; - }} - hoist - > - - -
- { - e.preventDefault(); - const id = new FormData(e.target as HTMLFormElement).get( - "crawlId", - ) as string; - this.routeTo(`/crawls/crawl/${id}#watch`); - void (e.target as HTMLFormElement).closest("sl-dropdown")?.hide(); - }} - > -
-
- -
-
- - - ${msg("Go")} -
-
- -
-
- `; - } - private showUserGuide(pathName?: string) { const iframe = this.userGuideDrawer.querySelector("iframe"); diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index 028f92c684..ae47dd1979 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -86,11 +86,17 @@ export function pageBack({ href, content }: Breadcrumb) { }); } -export function pageTitle(title?: string | TemplateResult | typeof nothing) { +export function pageTitle( + title?: string | TemplateResult | typeof nothing, + skeletonClass?: string, +) { return html`

${title || - html``} + html``}

`; } @@ -136,7 +142,7 @@ export function pageHeader({
${actions - ? html`
+ ? html`
${actions}
` : nothing} diff --git a/frontend/src/layouts/pageSectionsWithNav.ts b/frontend/src/layouts/pageSectionsWithNav.ts index 44c7db903f..577464ae6b 100644 --- a/frontend/src/layouts/pageSectionsWithNav.ts +++ b/frontend/src/layouts/pageSectionsWithNav.ts @@ -27,10 +27,11 @@ export function pageSectionsWithNav({ sticky && tw`lg:sticky lg:top-2 lg:self-start`, placement === "start" ? tw`lg:max-w-[16.5rem]` : tw`lg:flex-row`, )} + part="tabs" > ${nav}
-
${main}
+
${main}
`; } diff --git a/frontend/src/pages/admin.ts b/frontend/src/pages/admin.ts index 9d289e0ee0..cfa343e309 100644 --- a/frontend/src/pages/admin.ts +++ b/frontend/src/pages/admin.ts @@ -132,37 +132,6 @@ export class Admin extends BtrixElement { private renderAdminOrgs() { return html` -
-
{ - const formData = new FormData(e.target as HTMLFormElement); - const id = formData.get("crawlId"); - this.navigate.to(`/crawls/crawl/${id?.toString()}`); - }} - > -
-
- ${msg("Go to Crawl")} -
-
- -
-
- - - ${msg("Go")} -
-
-
-
-
diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts index 5d51379e83..d2307d9a62 100644 --- a/frontend/src/pages/collections/collection.ts +++ b/frontend/src/pages/collections/collection.ts @@ -104,26 +104,26 @@ export class Collection extends BtrixElement { : [], title: collection.name || "", actions: html` - ${when( - this.canEditCollection, - () => html` - - - - `, - )} - + ${when( + this.canEditCollection, + () => html` + + ${msg("Go to Private Page")} + + `, + )} `, }; @@ -227,7 +227,7 @@ export class Collection extends BtrixElement {
-

${msg("Metadata")}

+

${msg("Details")}

${metadata}
diff --git a/frontend/src/pages/crawls.ts b/frontend/src/pages/crawls.ts index bdd00a77ce..1ac147fab4 100644 --- a/frontend/src/pages/crawls.ts +++ b/frontend/src/pages/crawls.ts @@ -12,7 +12,7 @@ import { CrawlStatus } from "@/features/archived-items/crawl-status"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import type { Crawl } from "@/types/crawler"; import type { CrawlState } from "@/types/crawlState"; -import { activeCrawlStates } from "@/utils/crawler"; +import { activeCrawlStates, isActive } from "@/utils/crawler"; type SortField = "started" | "firstSeed" | "fileSize"; type SortDirection = "asc" | "desc"; @@ -68,16 +68,12 @@ export class Crawls extends BtrixElement { // Use to cancel requests private getCrawlsController: AbortController | null = null; - protected async willUpdate( + protected willUpdate( changedProperties: PropertyValues & Map, ) { if (changedProperties.has("crawlId") && this.crawlId) { // Redirect to org crawl page - await this.fetchWorkflowId(); - const slug = this.slugLookup[this.crawl!.oid]; - this.navigate.to( - `/orgs/${slug}/workflows/${this.crawl?.cid}/crawls/${this.crawlId}`, - ); + void this.fetchWorkflowId(); } else { if ( changedProperties.has("filterBy") || @@ -86,6 +82,16 @@ export class Crawls extends BtrixElement { void this.fetchCrawls(); } } + if (changedProperties.has("crawl") && this.crawl) { + const slug = this.slugLookup[this.crawl.oid]; + if (isActive(this.crawl)) { + this.navigate.to(`/orgs/${slug}/workflows/${this.crawl.cid}#cid`); + } else { + this.navigate.to( + `/orgs/${slug}/workflows/${this.crawl.cid}/crawls/${this.crawlId}`, + ); + } + } } firstUpdated() { @@ -283,14 +289,14 @@ export class Crawls extends BtrixElement { } private readonly renderCrawlItem = (crawl: Crawl) => { - const crawlPath = `/orgs/${this.slugLookup[crawl.oid]}/workflows/${crawl.cid}/crawls/${ - crawl.id - }`; + const crawlPath = `/orgs/${this.slugLookup[crawl.oid]}/workflows/${crawl.cid}`; return html` - + - this.navigate.to(`${crawlPath}#config`)}> - ${msg("View Crawl Settings")} + this.navigate.to(`${crawlPath}#settings`)} + > + ${msg("View Workflow Settings")} 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 49a201af3f..01591dbdc3 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 @@ -141,11 +141,6 @@ export class ArchivedItemDetail extends BtrixElement { private timerId?: number; - private get isActive(): boolean { - if (!this.item || this.item.type !== "crawl") return false; - return isActive(this.item); - } - private get hasFiles(): boolean | null { if (!this.item) return null; if (!this.item.resources) return false; @@ -203,28 +198,40 @@ export class ArchivedItemDetail extends BtrixElement { } } - // If item is a crawl and workflow id isn't set, redirect to item page with workflow prefix if ( - this.workflowId === "" && this.itemType === "crawl" && changedProperties.has("item") && this.item ) { - if (this.qaTab) { - // QA review open - this.navigate.to( - `${this.navigate.orgBasePath}/workflows/${this.item.cid}/crawls/${this.item.id}/review/${this.qaTab}${location.search}`, - undefined, - undefined, - true, - ); + if (this.workflowId) { + if (this.item.type === "crawl" && isActive(this.item)) { + // Items can technically be "running" on the backend, but only + // workflows should be considered running by the frontend + this.navigate.to( + `${this.navigate.orgBasePath}/workflows/${this.item.cid}#watch`, + undefined, + undefined, + true, + ); + } } else { - this.navigate.to( - `${this.navigate.orgBasePath}/workflows/${this.item.cid}/crawls/${this.item.id}#${this.activeTab}`, - undefined, - undefined, - true, - ); + // If item is a crawl and workflow ID isn't set, redirect to item page with workflow prefix + if (this.qaTab) { + // QA review open + this.navigate.to( + `${this.navigate.orgBasePath}/workflows/${this.item.cid}/crawls/${this.item.id}/review/${this.qaTab}${location.search}`, + undefined, + undefined, + true, + ); + } else { + this.navigate.to( + `${this.navigate.orgBasePath}/workflows/${this.item.cid}/crawls/${this.item.id}#${this.activeTab}`, + undefined, + undefined, + true, + ); + } } } } @@ -387,22 +394,12 @@ export class ArchivedItemDetail extends BtrixElement { ${when( this.isCrawler, () => html` - - - + `, )} `, @@ -596,24 +593,6 @@ export class ArchivedItemDetail extends BtrixElement {
- ${this.isActive - ? html` - - - - ${msg("Stop")} - - - - ${msg("Cancel")} - - - ` - : ""} ${this.isCrawler ? this.item ? this.renderMenu() @@ -634,9 +613,7 @@ export class ArchivedItemDetail extends BtrixElement { return html` ${this.isActive - ? html`` - : msg("Actions")}${msg("Actions")} ${when( @@ -696,8 +673,7 @@ export class ArchivedItemDetail extends BtrixElement { `, )} ${when( - this.isCrawler && - (this.item.type !== "crawl" || !isActive(this.item)), + this.isCrawler, () => html` ` : html`

- ${this.isActive - ? msg("No files yet.") - : msg("No files to replay.")} + ${msg("No files to replay.")}

` } @@ -853,43 +827,21 @@ export class ArchivedItemDetail extends BtrixElement { `, )} - + + ${this.item + ? html`${this.localize.number(this.item.pageCount || 0)} + ${pluralOf("pages", this.item.pageCount || 0)}` + : html``} + ${this.item ? html`${this.item.fileSize - ? html`${this.localize.bytes(this.item.fileSize || 0, { - unitDisplay: "narrow", - })}${this.item.stats?.done - ? html`, - ${this.localize.number(+this.item.stats.done)} - / - ${this.localize.number(+this.item.stats.found)} - - ${pluralOf("pages", +this.item.stats.found)}` - : html`, - ${this.localize.number( - this.item.pageCount ? +this.item.pageCount : 0, - )} - - ${pluralOf( - "pages", - this.item.pageCount ? +this.item.pageCount : 0, - )}`}` + ? this.localize.bytes(this.item.fileSize || 0) : html`${msg("Unknown")}`}` : html``} ${this.renderCrawlChannelVersion()} - + ${this.item ? html` - ${this.isActive - ? msg("No files yet.") - : msg("No files to download.")} + ${msg("No files to download.")}

`} `; diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 023d3ddc83..2c436da3e1 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -619,7 +619,7 @@ export class CrawlsList extends BtrixElement { @click=${() => ClipboardController.copyToClipboard(item.id)} > - ${msg("Copy Crawl ID")} + ${msg("Copy Archived Item ID")}
` : nothing} diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index 3a51731165..2367f17c29 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -1,3 +1,4 @@ +import { consume } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import clsx from "clsx"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; @@ -12,13 +13,16 @@ import type { Embed as ReplayWebPage } from "replaywebpage"; import { BtrixElement } from "@/classes/BtrixElement"; import type { MarkdownEditor } from "@/components/ui/markdown-editor"; import type { PageChangeEvent } from "@/components/ui/pagination"; +import { viewStateContext, type ViewStateContext } from "@/context/view-state"; +import type { EditDialogTab } from "@/features/collections/collection-edit-dialog"; +import { collectionShareLink } from "@/features/collections/helpers/share-link"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import type { ShareCollection } from "@/features/collections/share-collection"; import { metadataColumn, metadataItemWithCollection, } from "@/layouts/collections/metadataColumn"; -import { pageHeader, pageNav, type Breadcrumb } from "@/layouts/pageHeader"; +import { pageNav, pageTitle, type Breadcrumb } from "@/layouts/pageHeader"; import type { APIPaginatedList, APIPaginationQuery, @@ -60,11 +64,10 @@ export class CollectionDetail extends BtrixElement { private archivedItems?: APIPaginatedList; @state() - private openDialogName?: - | "delete" - | "editMetadata" - | "editItems" - | "editStartPage"; + private openDialogName?: "delete" | "edit" | "replaySettings" | "editItems"; + + @state() + private editTab?: EditDialogTab; @state() private isEditingDescription = false; @@ -72,6 +75,12 @@ export class CollectionDetail extends BtrixElement { @state() private isRwpLoaded = false; + @state() + private rwpDoFullReload = false; + + @consume({ context: viewStateContext }) + viewState?: ViewStateContext; + @query("replay-web-page") private readonly replayEmbed?: ReplayWebPage | null; @@ -102,6 +111,14 @@ export class CollectionDetail extends BtrixElement { }, }; + private get shareLink() { + return collectionShareLink( + this.collection, + this.orgSlugState, + this.viewState?.params.slug || "", + ); + } + private get isCrawler() { return this.appState.isCrawler; } @@ -135,29 +152,83 @@ export class CollectionDetail extends BtrixElement { } render() { - return html`
${this.renderBreadcrumbs()}
- ${pageHeader({ - title: this.collection?.name, - border: false, - prefix: this.renderAccessIcon(), - secondary: this.collection?.caption - ? html`
- ${this.collection.caption} -
` - : nothing, - actions: html` + return html` +
+ ${this.renderBreadcrumbs()} + ${this.collection && + (this.collection.access === CollectionAccess.Unlisted || + this.collection.access === CollectionAccess.Public) + ? html` + + + ${this.collection.access === CollectionAccess.Unlisted + ? msg("Go to Unlisted Page") + : msg("Go to Public Page")} + + ` + : nothing} +
+
+
+
+ ${this.renderAccessIcon()}${pageTitle( + this.collection?.name, + tw`mb-2 h-6 w-60`, + )} + ${this.collection && + html` { + this.openDialogName = "edit"; + this.editTab = "general"; + }} + >`} +
+ ${this.collection + ? this.collection.caption + ? html`
+ ${this.collection.caption} +
` + : html`
{ + this.openDialogName = "edit"; + this.editTab = "general"; + }} + > + ${msg("Add a summary...")} +
` + : html``} +
+ +
{ e.stopPropagation(); void this.fetchCollection(); }} > ${when(this.isCrawler, this.renderActions)} - `, - })} +
+
${this.renderInfoBar()} @@ -177,15 +248,20 @@ export class CollectionDetail extends BtrixElement { > (this.openDialogName = "editStartPage")} + @click=${() => { + this.openDialogName = "replaySettings"; + }} ?disabled=${!this.collection?.crawlCount || !this.isRwpLoaded} > ${!this.collection || Boolean(this.collection.crawlCount && !this.isRwpLoaded) ? html`` - : html``} - ${msg("Configure View")} + : html``} + ${msg("Set Initial View")} `, @@ -256,7 +332,7 @@ export class CollectionDetail extends BtrixElement { { // Don't do full refresh of rwp so that rwp-url-change fires this.isRwpLoaded = false; @@ -267,23 +343,29 @@ export class CollectionDetail extends BtrixElement { collectionId=${this.collectionId} .collection=${this.collection} ?replayLoaded=${this.isRwpLoaded} - > + > + - ${when( - this.collection, - () => html` - (this.openDialogName = undefined)} - @btrix-collection-saved=${() => { - this.refreshReplay(); - void this.fetchCollection(); - }} - > - - `, - )}`; + (this.openDialogName = undefined)} + @btrix-collection-saved=${() => { + this.refreshReplay(); + // TODO maybe we can return the updated collection from the update endpoint, and avoid an extra fetch? + void this.fetchCollection(); + }} + @btrix-change=${() => { + // Don't do full refresh of rwp so that rwp-url-change fires + this.isRwpLoaded = false; + + void this.fetchCollection(); + }} + .replayWebPage=${this.replayEmbed} + ?replayLoaded=${this.isRwpLoaded} + > + `; } private renderAccessIcon() { @@ -343,6 +425,8 @@ export class CollectionDetail extends BtrixElement { } catch (e) { console.warn("Full reload not available in RWP"); } + } else { + this.rwpDoFullReload = true; } } @@ -390,15 +474,22 @@ export class CollectionDetail extends BtrixElement { const authToken = this.authState?.headers.Authorization.split(" ")[1]; return html` + + { + this.openDialogName = "edit"; + this.editTab = "general"; + }} + > + + + ${msg("Actions")} - (this.openDialogName = "editMetadata")}> - - ${msg("Edit Metadata")} - { // replay-web-page needs to be available in order to configure start page @@ -409,37 +500,51 @@ export class CollectionDetail extends BtrixElement { await this.updateComplete; } - this.openDialogName = "editStartPage"; + this.openDialogName = "edit"; }} ?disabled=${!this.collection?.crawlCount} > - ${msg("Configure Replay View")} + ${msg("Edit Collection Settings")} + + { + this.openDialogName = "replaySettings"; + }} + ?disabled=${!this.collection?.crawlCount || !this.isRwpLoaded} + > + ${!this.collection || + Boolean(this.collection.crawlCount && !this.isRwpLoaded) + ? html`` + : html``} + ${msg("Set Initial View")} + + { - if (this.collectionTab !== Tab.About) { - this.navigate.to( - `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.About}`, - ); - await this.updateComplete; - } - + this.navigate.to( + `${this.navigate.orgBasePath}/collections/view/${this.collectionId}/${Tab.About}`, + ); this.isEditingDescription = true; + await this.updateComplete; + await this.descriptionEditor?.updateComplete; + void this.descriptionEditor?.focus(); }} > - - ${msg("Edit About Section")} + + ${msg("Edit Description")} (this.openDialogName = "editItems")}> ${msg("Select Archived Items")} - this.shareCollection?.show()}> - - ${msg("Share Collection")} - html` - +
-

${msg("Metadata")}

+

${msg("Details")}

${metadata}
@@ -803,6 +908,10 @@ export class CollectionDetail extends BtrixElement { if (!this.isRwpLoaded) { this.isRwpLoaded = true; } + if (this.rwpDoFullReload && this.replayEmbed) { + this.replayEmbed.fullReload(); + this.rwpDoFullReload = false; + } }} > `; @@ -944,6 +1053,7 @@ export class CollectionDetail extends BtrixElement { icon: "check2-circle", id: "collection-item-remove-status", }); + this.refreshReplay(); void this.fetchCollection(); void this.fetchArchivedItems({ // Update page if last item diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 567dafec5c..f240e7846a 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -14,7 +14,7 @@ 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 type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; import { emptyMessage } from "@/layouts/emptyMessage"; import { pageHeader } from "@/layouts/pageHeader"; @@ -107,7 +107,7 @@ export class CollectionsList extends BtrixElement { private searchResultsOpen = false; @state() - private openDialogName?: "create" | "delete" | "editMetadata"; + private openDialogName?: "create" | "delete" | "edit"; @state() private isDialogVisible = false; @@ -158,7 +158,9 @@ export class CollectionsList extends BtrixElement { variant="primary" size="small" ?disabled=${!this.org || this.org.readOnly} - @click=${() => (this.openDialogName = "create")} + @click=${() => { + this.openDialogName = "create"; + }} > ${msg("New Collection")} @@ -220,14 +222,8 @@ export class CollectionsList extends BtrixElement { )}
- (this.openDialogName = undefined)} @sl-after-hide=${() => (this.selectedCollection = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { @@ -240,7 +236,20 @@ export class CollectionsList extends BtrixElement { } }} > - + + { + this.openDialogName = undefined; + }} + @sl-after-hide=${() => { + this.selectedCollection = undefined; + }} + @btrix-collection-saved=${() => { + void this.fetchCollections(); + }} + > `; } @@ -572,84 +581,30 @@ export class CollectionsList extends BtrixElement { return html` - void this.manageCollection(col, "editMetadata")} - > - - ${msg("Edit Metadata")} + void this.manageCollection(col, "edit")}> + + ${msg("Edit Collection Settings")} - ${col.access === CollectionAccess.Private + ${col.access === CollectionAccess.Public || + col.access === CollectionAccess.Unlisted ? html` - - void this.updateAccess(col, CollectionAccess.Unlisted)} - > - - ${msg("Enable Share Link")} - - ` - : html` { ClipboardController.copyToClipboard(this.getShareLink(col)); this.notify.toast({ message: msg("Link copied"), + variant: "success", + icon: "check2-circle", }); }} > ${msg("Copy Share Link")} - ${col.access === CollectionAccess.Public - ? html` - - void this.updateAccess( - col, - CollectionAccess.Unlisted, - )} - > - - ${msg("Make Unlisted")} - - ` - : this.org?.enablePublicProfile - ? html` - - void this.updateAccess( - col, - CollectionAccess.Public, - )} - > - - ${msg("Make Public")} - - ` - : nothing} - - void this.updateAccess(col, CollectionAccess.Private)} - > - - ${msg("Make Private")} - - `} + ` + : nothing} [this.orgId] as const, @@ -89,11 +93,47 @@ export class Dashboard extends BtrixElement { return html` ${pageHeader({ title: this.userOrg?.name, + secondary: html` + ${when( + this.org?.publicDescription, + (publicDescription) => html` +
${publicDescription}
+ `, + )} + ${when(this.org?.publicUrl, (urlStr) => { + let url: URL; + try { + url = new URL(urlStr); + } catch { + return nothing; + } + + return html` + + `; + })} + `, actions: html` ${when( this.appState.isAdmin, () => - html` + html` html` - - - + ${when( + this.appState.isCrawler, + () => html` + + ${when(this.org, (org) => + org.enablePublicProfile + ? html` ` + : nothing, + )} + + - - ${msg("Manage Collections")} - - - - ${this.org?.enablePublicProfile - ? msg("Visit Public Collections Gallery") - : msg("Preview Public Collections Gallery")} - - ${when(this.org, (org) => - org.enablePublicProfile - ? html` - { - ClipboardController.copyToClipboard( - `${window.location.protocol}//${window.location.hostname}${ - window.location.port - ? `:${window.location.port}` - : "" - }/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`, - ); - this.notify.toast({ - message: msg("Link copied"), - }); - }} - > - - ${msg("Copy Link to Public Gallery")} - - ` - : this.appState.isAdmin - ? html` - - - - ${msg("Update Org Profile")} - - ` - : nothing, - )} - - - `, - )} + class="size-8 text-base" + name="collection" + @click=${this.navigate.link} + > + + `, + )} + + + +
{ + this.collectionRefreshing = e.detail.id; + void this.publicCollections.run([this.orgId]); + }} > ${this.renderNoPublicCollections()} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index cc68523188..3b0191a2a7 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -20,7 +20,7 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { proxiesContext, type ProxiesContext } from "@/context/org"; import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; -import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog"; +import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { OrgTab, RouteNamespace } from "@/routes"; import type { ProxiesAPIResponse } from "@/types/crawler"; @@ -281,7 +281,7 @@ export class Org extends BtrixElement {
${when(this.userOrg, (userOrg) => @@ -445,7 +445,7 @@ export class Org extends BtrixElement { @sl-hide=${() => (this.openDialogName = undefined)} > - (this.openDialogName = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { @@ -454,7 +454,7 @@ export class Org extends BtrixElement { ); }} > - +
`; } diff --git a/frontend/src/pages/org/settings/components/general.ts b/frontend/src/pages/org/settings/components/general.ts new file mode 100644 index 0000000000..dea9cc1007 --- /dev/null +++ b/frontend/src/pages/org/settings/components/general.ts @@ -0,0 +1,408 @@ +import { localized, msg } from "@lit/localize"; +import type { SlInput } from "@shoelace-style/shoelace"; +import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; +import isEqual from "lodash/fp/isEqual"; + +import { UPDATED_STATUS_TOAST_ID, type UpdateOrgDetail } from "../settings"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { APIUser } from "@/index"; +import { columns } from "@/layouts/columns"; +import { RouteNamespace } from "@/routes"; +import { alerts } from "@/strings/orgs/alerts"; +import { isApiError } from "@/utils/api"; +import { formValidator, maxLengthValidator } from "@/utils/form"; +import slugifyStrict from "@/utils/slugify"; +import { AppStateService } from "@/utils/state"; +import { formatAPIUser } from "@/utils/user"; + +type InfoParams = { + orgName: string; + orgSlug: string; +}; + +type ProfileParams = { + enablePublicProfile: boolean; + publicDescription: string; + publicUrl: string; +}; + +/** + * @fires btrix-update-org + */ +@localized() +@customElement("btrix-org-settings-general") +export class OrgSettingsGeneral extends BtrixElement { + @state() + private isSubmitting = false; + + @state() + private slugValue = ""; + + private readonly checkFormValidity = formValidator(this); + private readonly validateOrgNameMax = maxLengthValidator(40); + private readonly validateDescriptionMax = maxLengthValidator(150); + + private get baseUrl() { + return `${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + } + + private get slugPreview() { + return this.slugValue ? slugifyStrict(this.slugValue) : this.userOrg?.slug; + } + + render() { + if (!this.userOrg) return; + + const baseUrl = this.baseUrl; + const slugPreview = this.slugPreview; + + return html`
+
+
+ ${columns([ + [ + html` + + `, + msg( + "Choose a name that represents your organization, your team, or your personal web archive.", + ), + ], + [ + html` + +
+ ${baseUrl}/ +
+
+ ${msg("Examples of org URL in use")}: +
    +
  • + ${msg("Settings")} ${msg("(current page)")}: + + /${RouteNamespace.PrivateOrgs}/${slugPreview}/settings + +
  • + + ${this.org?.enablePublicProfile + ? html` +
  • + ${msg("Public collections gallery")}: + + /${RouteNamespace.PublicOrgs}/${slugPreview} + +
  • + ` + : html` +
  • + ${msg("Dashboard")}: + + /${RouteNamespace.PrivateOrgs}/${slugPreview}/dashboard + +
  • + `} +
+
+
+ `, + msg("Customize your org's Browsertrix URL."), + ], + ])} + +
${this.renderPublicGallerySettings()}
+
+
+ ${when( + this.org?.enablePublicProfile, + () => html` + + ${msg("View public collections gallery")} + + `, + )} + + ${msg("Save")} + +
+
+
`; + } + + private renderPublicGallerySettings() { + const baseUrl = this.baseUrl; + const slugPreview = this.slugPreview; + const publicGalleryUrl = `${window.location.protocol}//${baseUrl}/${RouteNamespace.PublicOrgs}/${slugPreview}`; + + return html` + + ${msg("Public Collections Gallery")} + + ${columns([ + [ + html` +
+ + ${msg("Enable public collections gallery")} + +
+ + + + + + + `, + msg( + "If enabled, anyone on the Internet will be able to visit this URL to browse public collections and view general org information.", + ), + ], + [ + html` + + `, + msg( + "Write a short description that introduces your org and its public collections.", + ), + ], + [ + html` + + `, + msg("Link to your organization's (or your personal) website."), + ], + ])} + `; + } + + private handleSlugInput(e: InputEvent) { + const input = e.target as SlInput; + // Ideally this would match against the full character map that slugify uses + // but this'll do for most use cases + const end = input.value.match(/[\s*_+~.,()'"!\-:@]$/g) ? "-" : ""; + input.value = slugifyStrict(input.value) + end; + this.slugValue = slugifyStrict(input.value); + + input.setCustomValidity( + this.slugValue.length < 2 ? msg("URL too short") : "", + ); + } + + private async onSubmit(e: SubmitEvent) { + e.preventDefault(); + + const formEl = e.target as HTMLFormElement; + if (!(await this.checkFormValidity(formEl)) || !this.org) return; + + const { + orgName, + orgSlug, + publicDescription, + publicUrl, + enablePublicProfile, + } = serialize(formEl) as InfoParams & + ProfileParams & { + enablePublicProfile: undefined | "on"; + }; + + // TODO See if backend can combine into one endpoint + const requests: Promise[] = []; + + const infoParams = { + name: orgName, + slug: this.slugValue ? slugifyStrict(this.slugValue) : orgSlug, + }; + const infoChanged = !isEqual(infoParams)({ + name: this.org.name, + slug: this.org.slug, + }); + + if (infoChanged) { + requests.push(this.renameOrg(infoParams)); + } + + const profileParams: ProfileParams = { + enablePublicProfile: enablePublicProfile === "on", + publicDescription, + publicUrl, + }; + const profileChanged = !isEqual(profileParams, { + enablePublicProfile: this.org.enablePublicProfile, + publicDescription: this.org.publicDescription, + publicUrl: this.org.publicUrl, + }); + + if (profileChanged) { + requests.push(this.updateProfile(profileParams)); + } + + this.isSubmitting = true; + + try { + await Promise.all(requests); + + this.notify.toast({ + message: alerts.settingsUpdateSuccess, + variant: "success", + icon: "check2-circle", + id: UPDATED_STATUS_TOAST_ID, + }); + } catch (err) { + console.debug(err); + + let message = alerts.settingsUpdateFailure; + + if (isApiError(err)) { + if (err.details === "duplicate_org_name") { + message = msg("This org name is already taken, try another one."); + } else if (err.details === "duplicate_org_slug") { + message = msg("This org URL is already taken, try another one."); + } else if (err.details === "invalid_slug") { + message = msg( + "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", + ); + } + } + + this.notify.toast({ + message, + variant: "danger", + icon: "exclamation-octagon", + id: UPDATED_STATUS_TOAST_ID, + }); + } + + this.isSubmitting = false; + } + + private async renameOrg({ name, slug }: { name: string; slug: string }) { + await this.api.fetch(`/orgs/${this.orgId}/rename`, { + method: "POST", + body: JSON.stringify({ name, slug }), + }); + + const user = await this.getCurrentUser(); + + AppStateService.updateUser(formatAPIUser(user), slug); + + await this.updateComplete; + + this.navigate.to(`${this.navigate.orgBasePath}/settings`); + } + + private async updateProfile({ + enablePublicProfile, + publicDescription, + publicUrl, + }: ProfileParams) { + const data = await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/public-profile`, + { + method: "POST", + body: JSON.stringify({ + enablePublicProfile, + publicDescription, + publicUrl, + }), + }, + ); + + if (!data.updated) { + throw new Error("`data.updated` is not true"); + } + + this.dispatchEvent( + new CustomEvent("btrix-update-org", { + detail: { + publicDescription, + publicUrl, + }, + bubbles: true, + composed: true, + }), + ); + } + + private async getCurrentUser(): Promise { + return this.api.fetch("/users/me"); + } +} diff --git a/frontend/src/pages/org/settings/components/visibility.ts b/frontend/src/pages/org/settings/components/visibility.ts deleted file mode 100644 index aadbbd0216..0000000000 --- a/frontend/src/pages/org/settings/components/visibility.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { localized, msg } from "@lit/localize"; -import type { SlChangeEvent, SlSwitch } from "@shoelace-style/shoelace"; -import { html } from "lit"; -import { customElement } from "lit/decorators.js"; - -import { UPDATED_STATUS_TOAST_ID, type UpdateOrgDetail } from "../settings"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import { columns, type Cols } from "@/layouts/columns"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/orgs/alerts"; - -/** - * @fires btrix-update-org - */ -@localized() -@customElement("btrix-org-settings-visibility") -export class OrgSettingsVisibility extends BtrixElement { - render() { - const orgBaseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; - - const cols: Cols = [ - [ - html` - -
- - ${msg("Enable gallery of public collections")} - -
- `, - msg( - "If enabled, anyone on the Internet will be able to browse this org's public collections and view general org information.", - ), - ], - [ - html` -
- -
- `, - html` - ${msg( - html`To customize this URL, - ${msg("update your Org URL in General settings")}.`, - )} - `, - ], - ]; - - return html` -

- ${msg("Public Collections Gallery")} -

- -
-
${columns(cols)}
-
- `; - } - - private readonly onVisibilityChange = async (e: SlChangeEvent) => { - const checked = (e.target as SlSwitch).checked; - - if (checked === this.org?.enablePublicProfile) { - return; - } - - try { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/public-profile`, - { - method: "POST", - body: JSON.stringify({ - enablePublicProfile: checked, - }), - }, - ); - - if (!data.updated) { - throw new Error("`data.updated` is not true"); - } - - this.dispatchEvent( - new CustomEvent("btrix-update-org", { - detail: { - enablePublicProfile: checked, - }, - bubbles: true, - composed: true, - }), - ); - - this.notify.toast({ - message: msg("Updated public collections gallery visibility."), - variant: "success", - icon: "check2-circle", - id: UPDATED_STATUS_TOAST_ID, - }); - } catch (err) { - console.debug(err); - - this.notify.toast({ - message: alerts.settingsUpdateFailure, - variant: "danger", - icon: "exclamation-octagon", - id: UPDATED_STATUS_TOAST_ID, - }); - } - }; -} diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index ea5c6ef625..6ea4bcea41 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -1,5 +1,4 @@ import { localized, msg, str } from "@lit/localize"; -import type { SlInput } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html, @@ -16,23 +15,17 @@ import { when } from "lit/directives/when.js"; import stylesheet from "./settings.stylesheet.css"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { APIUser } from "@/index"; import { columns } from "@/layouts/columns"; import { pageHeader } from "@/layouts/pageHeader"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/orgs/alerts"; import type { APIPaginatedList } from "@/types/api"; import { isApiError } from "@/utils/api"; -import { formValidator, maxLengthValidator } from "@/utils/form"; +import { formValidator } from "@/utils/form"; import { AccessCode, isAdmin, isCrawler, type OrgData } from "@/utils/orgs"; -import slugifyStrict from "@/utils/slugify"; -import { AppStateService } from "@/utils/state"; import { tw } from "@/utils/tailwind"; -import { formatAPIUser } from "@/utils/user"; +import "./components/general"; import "./components/billing"; import "./components/crawling-defaults"; -import "./components/visibility"; const styles = unsafeCSS(stylesheet); @@ -84,9 +77,6 @@ export class OrgSettings extends BtrixElement { @property({ type: Boolean }) isAddingMember = false; - @state() - private isSavingOrgName = false; - @state() private pendingInvites: Invite[] = []; @@ -96,9 +86,6 @@ export class OrgSettings extends BtrixElement { @state() private isSubmittingInvite = false; - @state() - private slugValue = ""; - private get tabLabels(): Record { return { information: msg("General"), @@ -108,9 +95,6 @@ export class OrgSettings extends BtrixElement { }; } - private readonly validateOrgNameMax = maxLengthValidator(40); - private readonly validateDescriptionMax = maxLengthValidator(150); - async willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("isAddingMember") && this.isAddingMember) { this.isAddMemberFormVisible = true; @@ -177,8 +161,7 @@ export class OrgSettings extends BtrixElement { ${this.renderPanelHeader({ title: msg("General") })} - ${this.renderInformation()} - + ${this.renderApi()} @@ -270,113 +253,6 @@ export class OrgSettings extends BtrixElement { `; } - private renderInformation() { - if (!this.userOrg) return; - - return html`
-
-
- ${columns([ - [ - html` - - `, - msg( - "Choose a name that represents your organization, your team, or your personal web archive.", - ), - ], - [ - html` - -
/
-
- `, - msg("Customize your org's Browsertrix URL."), - ], - [ - html` - - `, - msg("Write a short description to introduce your organization."), - ], - [ - html` - - `, - msg("Link to your organization's (or your personal) website."), - ], - ])} -
-
- - ${when( - this.org, - (org) => - org.enablePublicProfile - ? msg("View as public") - : msg("Preview how information appears to the public"), - () => html` `, - )} - - - ${msg("Save")} - -
-
-
`; - } - private renderApi() { if (!this.userOrg) return; @@ -402,19 +278,6 @@ export class OrgSettings extends BtrixElement { `; } - private handleSlugInput(e: InputEvent) { - const input = e.target as SlInput; - // Ideally this would match against the full character map that slugify uses - // but this'll do for most use cases - const end = input.value.match(/[\s*_+~.,()'"!\-:@]$/g) ? "-" : ""; - input.value = slugifyStrict(input.value) + end; - this.slugValue = slugifyStrict(input.value); - - input.setCustomValidity( - this.slugValue.length < 2 ? msg("URL Identifier too short") : "", - ); - } - private renderMembers() { if (!this.org?.users) return; @@ -707,87 +570,6 @@ export class OrgSettings extends BtrixElement { } } - private async onOrgInfoSubmit(e: SubmitEvent) { - e.preventDefault(); - - const formEl = e.target as HTMLFormElement; - if (!(await this.checkFormValidity(formEl)) || !this.org) return; - - const { orgName, publicDescription, publicUrl } = serialize(formEl) as { - orgName: string; - publicDescription: string; - publicUrl: string; - }; - - // TODO See if backend can combine into one endpoint - const requests: Promise[] = []; - - if (orgName !== this.org.name || this.slugValue) { - const params = { - name: orgName, - slug: this.orgSlugState!, - }; - - if (this.slugValue) { - params.slug = slugifyStrict(this.slugValue); - } - - requests.push(this.renameOrg(params)); - } - - if ( - publicDescription !== (this.org.publicDescription ?? "") || - publicUrl !== (this.org.publicUrl ?? "") - ) { - requests.push( - this.updateOrgProfile({ - publicDescription: publicDescription || this.org.publicDescription, - publicUrl: publicUrl || this.org.publicUrl, - }), - ); - } - - if (requests.length) { - this.isSavingOrgName = true; - - try { - await Promise.all(requests); - - this.notify.toast({ - message: alerts.settingsUpdateSuccess, - variant: "success", - icon: "check2-circle", - id: UPDATED_STATUS_TOAST_ID, - }); - } catch (err) { - console.debug(err); - - let message = alerts.settingsUpdateFailure; - - if (isApiError(err)) { - if (err.details === "duplicate_org_name") { - message = msg("This org name is already taken, try another one."); - } else if (err.details === "duplicate_org_slug") { - message = msg("This org URL is already taken, try another one."); - } else if (err.details === "invalid_slug") { - message = msg( - "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", - ); - } - } - - this.notify.toast({ - message, - variant: "danger", - icon: "exclamation-octagon", - id: UPDATED_STATUS_TOAST_ID, - }); - } - - this.isSavingOrgName = false; - } - } - private readonly selectUserRole = (user: User) => (e: Event) => { this.dispatchEvent( new CustomEvent("org-user-role-change", { @@ -876,57 +658,6 @@ export class OrgSettings extends BtrixElement { } } - private async renameOrg({ name, slug }: { name: string; slug: string }) { - await this.api.fetch(`/orgs/${this.orgId}/rename`, { - method: "POST", - body: JSON.stringify({ name, slug }), - }); - - const user = await this.getCurrentUser(); - - AppStateService.updateUser(formatAPIUser(user), slug); - - this.navigate.to(`${this.navigate.orgBasePath}/settings`); - } - - private async updateOrgProfile({ - publicDescription, - publicUrl, - }: { - publicDescription: string | null; - publicUrl: string | null; - }) { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/public-profile`, - { - method: "POST", - body: JSON.stringify({ - publicDescription, - publicUrl, - }), - }, - ); - - if (!data.updated) { - throw new Error("`data.updated` is not true"); - } - - this.dispatchEvent( - new CustomEvent("btrix-update-org", { - detail: { - publicDescription, - publicUrl, - }, - bubbles: true, - composed: true, - }), - ); - } - - private async getCurrentUser(): Promise { - return this.api.fetch("/users/me"); - } - /** * Stop propgation of sl-tooltip events. * Prevents bug where sl-dialog closes when tooltip closes diff --git a/frontend/src/pages/org/workflow-detail.ts b/frontend/src/pages/org/workflow-detail.ts index 8e096cd2ec..f4bedb46f6 100644 --- a/frontend/src/pages/org/workflow-detail.ts +++ b/frontend/src/pages/org/workflow-detail.ts @@ -504,15 +504,17 @@ export class WorkflowDetail extends BtrixElement { } if (this.activePanel === "settings" && this.isCrawler) { return html`

${this.tabLabels[this.activePanel]}

- - this.navigate.to( - `/orgs/${this.appState.orgSlug}/workflows/${this.workflow?.id}?edit`, - )} - > - `; + + + this.navigate.to( + `/orgs/${this.appState.orgSlug}/workflows/${this.workflow?.id}?edit`, + )} + > + + `; } if (this.activePanel === "watch" && this.isCrawler) { const enableEditBrowserWindows = @@ -916,7 +918,11 @@ export class WorkflowDetail extends BtrixElement { this.crawls!.items.map( (crawl: Crawl) => html` ${when( diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts index 4809ace754..2770a89ab1 100644 --- a/frontend/src/pages/public/org.ts +++ b/frontend/src/pages/public/org.ts @@ -129,15 +129,24 @@ export class PublicOrg extends BtrixElement { : nothing, actions: when( this.canEditOrg, - () => - html` + () => html` + + + + - `, + + `, ), secondary: html` ${when( @@ -190,11 +199,11 @@ export class PublicOrg extends BtrixElement { ${when( this.canEditOrg, () => - html` + html` `, diff --git a/frontend/src/types/collection.ts b/frontend/src/types/collection.ts index 4ec6f8fe6c..8e148e5188 100644 --- a/frontend/src/types/collection.ts +++ b/frontend/src/types/collection.ts @@ -6,6 +6,16 @@ export enum CollectionAccess { Unlisted = "unlisted", } +export const collectionThumbnailSourceSchema = z.object({ + url: z.string().url(), + urlPageId: z.string().url(), + urlTs: z.string().datetime(), +}); + +export type CollectionThumbnailSource = z.infer< + typeof collectionThumbnailSourceSchema +>; + export const publicCollectionSchema = z.object({ id: z.string(), slug: z.string(), @@ -24,6 +34,7 @@ export const publicCollectionSchema = z.object({ path: z.string().url(), }) .nullable(), + thumbnailSource: collectionThumbnailSourceSchema.nullable(), defaultThumbnailName: z.string().nullable(), crawlCount: z.number(), uniquePageCount: z.number(), @@ -33,6 +44,7 @@ export const publicCollectionSchema = z.object({ homeUrl: z.string().url().nullable(), homeUrlPageId: z.string().url().nullable(), homeUrlTs: z.string().datetime().nullable(), + access: z.nativeEnum(CollectionAccess), }); export type PublicCollection = z.infer; @@ -45,3 +57,18 @@ export type Collection = z.infer; export type CollectionSearchValues = { names: string[]; }; + +export const collectionUpdateSchema = z + .object({ + slug: z.string(), + name: z.string(), + description: z.string(), + caption: z.string(), + access: z.string(), + defaultThumbnailName: z.string().nullable(), + allowPublicDownload: z.boolean(), + thumbnailSource: collectionThumbnailSourceSchema.nullable(), + }) + .partial(); + +export type CollectionUpdate = z.infer; diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index fde88e1827..93db4d1bd7 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -7,7 +7,7 @@ import localize from "./localize"; export type MaxLengthValidator = { helpText: string; - validate: (e: CustomEvent) => void; + validate: (e: CustomEvent) => boolean; }; export function getHelpText(maxLength: number, currentLength: number) { @@ -58,6 +58,7 @@ export function maxLengthValidator(maxLength: number): MaxLengthValidator { ); el.helpText = isInvalid ? validityText : origHelpText || validityHelpText; + return !isInvalid; }; return { helpText: validityHelpText, validate };