diff --git a/backend/btrixcloud/colls.py b/backend/btrixcloud/colls.py index f71313d2af..09a0a6158c 100644 --- a/backend/btrixcloud/colls.py +++ b/backend/btrixcloud/colls.py @@ -396,7 +396,7 @@ async def list_collections( page = page - 1 skip = page * page_size - match_query: dict[str, object] = {"oid": org.id} + match_query: Dict[str, Union[str, UUID, int, object]] = {"oid": org.id} if name: match_query["name"] = name @@ -409,15 +409,33 @@ async def list_collections( elif access: match_query["access"] = access - aggregate = [{"$match": match_query}] + aggregate: List[Dict[str, Union[str, UUID, int, object]]] = [ + {"$match": match_query} + ] if sort_by: - if sort_by not in ("modified", "name", "description", "totalSize"): + if sort_by not in ( + "created", + "modified", + "dateLatest", + "name", + "crawlCount", + "pageCount", + "totalSize", + "description", + "caption", + ): 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}}]) + sort_query = {sort_by: sort_direction} + + # add secondary sort keys: + if sort_by == "dateLatest": + sort_query["dateEarliest"] = sort_direction + + aggregate.extend([{"$sort": sort_query}]) aggregate.extend( [ diff --git a/frontend/src/features/collections/collection-metadata-dialog.ts b/frontend/src/features/collections/collection-metadata-dialog.ts index fcbb6b7da4..160ce1e468 100644 --- a/frontend/src/features/collections/collection-metadata-dialog.ts +++ b/frontend/src/features/collections/collection-metadata-dialog.ts @@ -135,12 +135,13 @@ export class CollectionMetadataDialog extends BtrixElement { ${msg( - "Write a short description that summarizes this collection. If the collection is public, this description will be visible next to the collection name.", + "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( - "You can write a longer description in the 'About' section after creating the collection.", + html`You can add a longer description in the “About” + section after creating the collection.`, )} TemplateResult | string; + }) { + return html` + + ${when( + collection, + render, + () => html``, + )} + + `; + }; +} + +export function metadataColumn(collection?: Collection | PublicCollection) { + const metadataItem = metadataItemWithCollection(collection); + + return html` + + ${metadataItem({ + label: msg("Collection Period"), + render: (col) => html` + + ${monthYearDateRange(col.dateEarliest, col.dateLatest)} + + `, + })} + ${metadataItem({ + label: msg("Pages in Collection"), + render: (col) => + `${localize.number(col.pageCount)} ${pluralOf("pages", col.pageCount)}`, + })} + ${metadataItem({ + label: msg("Total Page Snapshots"), + render: (col) => + `${localize.number(col.snapshotCount)} ${pluralOf("snapshots", col.snapshotCount)}`, + })} + + `; +} diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts index b7d6b24314..5d51379e83 100644 --- a/frontend/src/pages/collections/collection.ts +++ b/frontend/src/pages/collections/collection.ts @@ -1,4 +1,4 @@ -import { localized, msg, str } from "@lit/localize"; +import { localized, msg } from "@lit/localize"; import { Task, TaskStatus } from "@lit/task"; import { html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -6,6 +6,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; +import { metadataColumn } from "@/layouts/collections/metadataColumn"; import { page } from "@/layouts/page"; import { RouteNamespace } from "@/routes"; import type { PublicCollection } from "@/types/collection"; @@ -211,39 +212,8 @@ export class Collection extends BtrixElement { `; } - // TODO Consolidate with collection-detail.ts private renderAbout(collection: PublicCollection) { - const dateRange = () => { - if (!collection.dateEarliest || !collection.dateLatest) { - return msg("n/a"); - } - const format: Intl.DateTimeFormatOptions = { - month: "long", - year: "numeric", - }; - const dateEarliest = this.localize.date(collection.dateEarliest, format); - const dateLatest = this.localize.date(collection.dateLatest, format); - - if (dateEarliest === dateLatest) return dateLatest; - - return msg(str`${dateEarliest} to ${dateLatest}`, { - desc: "Date range formatted to show full month name and year", - }); - }; - - const metadata = html` - - - ${dateRange()} - - - ${this.localize.number(collection.pageCount)} - - - ${this.localize.bytes(collection.totalSize)} - - - `; + const metadata = metadataColumn(collection); if (collection.description) { return html` diff --git a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts index 5d5ea682e3..a2192d309b 100644 --- a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts +++ b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts @@ -38,6 +38,7 @@ import type { } from "@/types/api"; import type { ArchivedItem, ArchivedItemPageComment } from "@/types/crawler"; import type { ArchivedItemQAPage, QARun } from "@/types/qa"; +import { SortDirection as APISortDirection } from "@/types/utils"; import { isActive, isSuccessfullyFinished, @@ -553,7 +554,8 @@ export class ArchivedItemQA extends BtrixElement { .pages=${this.pages} .orderBy=${{ field: this.sortPagesBy.sortBy, - direction: (this.sortPagesBy.sortDirection === -1 + direction: (this.sortPagesBy.sortDirection === + APISortDirection.Descending ? "desc" : "asc") as SortDirection, }} diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index 9073a47284..ce7ad24c87 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -14,13 +14,21 @@ import type { MarkdownEditor } from "@/components/ui/markdown-editor"; import type { PageChangeEvent } from "@/components/ui/pagination"; 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 type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; -import { CollectionAccess, type Collection } from "@/types/collection"; +import { + CollectionAccess, + type Collection, + type PublicCollection, +} from "@/types/collection"; import type { ArchivedItem, Crawl, Upload } from "@/types/crawler"; import type { CrawlState } from "@/types/crawlState"; import { pluralOf } from "@/utils/pluralize"; @@ -43,7 +51,7 @@ export class CollectionDetail extends BtrixElement { collectionId!: string; @property({ type: String }) - collectionTab: Tab = Tab.Replay; + collectionTab: Tab | null = Tab.Replay; @state() private collection?: Collection; @@ -105,6 +113,9 @@ export class CollectionDetail extends BtrixElement { void this.fetchCollection(); void this.fetchArchivedItems({ page: 1 }); } + if (changedProperties.has("collectionTab") && this.collectionTab === null) { + this.collectionTab = Tab.Replay; + } } protected async updated( @@ -472,16 +483,6 @@ export class CollectionDetail extends BtrixElement { (col) => `${this.localize.number(col.crawlCount)} ${pluralOf("items", col.crawlCount)}`, )} - ${this.renderDetailItem(msg("Total Size"), (col) => - this.localize.bytes(col.totalSize || 0, { - unitDisplay: "narrow", - }), - )} - ${this.renderDetailItem( - msg("Total Pages"), - (col) => - `${this.localize.number(col.pageCount)} ${pluralOf("pages", col.pageCount)}`, - )} ${when(this.collection?.created, (created) => // Collections created before 49516bc4 is released may not have date in db created @@ -495,12 +496,13 @@ export class CollectionDetail extends BtrixElement { year="numeric" hour="numeric" minute="numeric" + time-zone-name="short" >`, ) : nothing, )} ${this.renderDetailItem( - msg("Last Updated"), + msg("Last Modified"), (col) => html``, )} @@ -517,67 +520,58 @@ export class CollectionDetail extends BtrixElement { private renderDetailItem( label: string | TemplateResult, - renderContent: (collection: Collection) => TemplateResult | string, + renderContent: (collection: PublicCollection) => TemplateResult | string, ) { - return html` - - ${when( - this.collection, - () => renderContent(this.collection!), - () => html``, - )} - - `; + return metadataItemWithCollection(this.collection)({ + label, + render: renderContent, + }); } - // TODO Consolidate with collection.ts private renderAbout() { - const dateRange = (collection: Collection) => { - if (!collection.dateEarliest || !collection.dateLatest) { - return msg("n/a"); - } - const format: Intl.DateTimeFormatOptions = { - month: "long", - year: "numeric", - }; - const dateEarliest = this.localize.date(collection.dateEarliest, format); - const dateLatest = this.localize.date(collection.dateLatest, format); - - if (dateEarliest === dateLatest) return dateLatest; - - return msg(str`${dateEarliest} to ${dateLatest}`, { - desc: "Date range formatted to show full month name and year", - }); - }; - const skeleton = html``; - - const metadata = html` - - - ${this.collection ? dateRange(this.collection) : skeleton} - - - `; + const metadata = metadataColumn(this.collection); return html`
-
-

- ${msg("Description")} -

+
+
+

+ ${msg("About This Collection")} +

+ +
+

+ ${msg( + html`Describe your collection in long-form rich text (e.g. + bold and italicized text.)`, + )} +

+

+ ${msg( + html`If this collection is shareable, this will appear in + the “About This Collection” section of the shared + collection.`, + )} +

+
+ +
+
${when( this.collection?.description && !this.isEditingDescription, () => html` - (this.isEditingDescription = true)} - > - - ${msg("Edit Description")} - + + (this.isEditingDescription = true)} + > + + `, )}
@@ -602,7 +596,7 @@ export class CollectionDetail extends BtrixElement { ` : html`
-

+

${msg("No description provided.")}

= { - modified: { - label: msg("Last Updated"), - defaultDirection: "desc", - }, name: { label: msg("Name"), - defaultDirection: "asc", + defaultDirection: SortDirection.Ascending, + }, + dateLatest: { + label: msg("Collection Period"), + defaultDirection: SortDirection.Descending, + }, + crawlCount: { + label: msg("Archived Items"), + defaultDirection: SortDirection.Descending, + }, + pageCount: { + label: msg("Pages"), + defaultDirection: SortDirection.Descending, }, totalSize: { label: msg("Size"), - defaultDirection: "desc", + defaultDirection: SortDirection.Descending, + }, + modified: { + label: msg("Last Modified"), + defaultDirection: SortDirection.Descending, }, }; const MIN_SEARCH_LENGTH = 2; @@ -269,7 +287,7 @@ export class CollectionsList extends BtrixElement { @click=${() => { this.orderBy = { ...this.orderBy, - direction: this.orderBy.direction === "asc" ? "desc" : "asc", + direction: -1 * this.orderBy.direction, }; }} > @@ -363,24 +381,22 @@ export class CollectionsList extends BtrixElement { return html` ${msg("Collection Access")} - ${msg("Name")} - - ${msg("Archived Items")} - - ${msg("Total Size")} + ${msg(html`Name & Collection Period`)} - ${msg("Total Pages")} + ${msg("Archived Items")} + ${msg("Pages")} + ${msg("Size")} - ${msg("Last Updated")} + ${msg("Last Modified")} ${msg("Row Actions")} @@ -514,30 +530,31 @@ export class CollectionsList extends BtrixElement { href=${`${this.navigate.orgBasePath}/collections/view/${col.id}`} @click=${this.navigate.link} > - ${col.name} +
${col.name}
+
+ ${monthYearDateRange(col.dateEarliest, col.dateLatest)} +
${this.localize.number(col.crawlCount, { notation: "compact" })} ${pluralOf("items", col.crawlCount)} + + ${this.localize.number(col.pageCount, { notation: "compact" })} + ${pluralOf("pages", col.pageCount)} + ${this.localize.bytes(col.totalSize || 0, { unitDisplay: "narrow", })} - - ${this.localize.number(col.pageCount, { notation: "compact" })} - ${pluralOf("pages", col.pageCount)} - @@ -783,7 +800,7 @@ export class CollectionsList extends BtrixElement { this.collections?.pageSize || INITIAL_PAGE_SIZE, sortBy: this.orderBy.field, - sortDirection: this.orderBy.direction === "desc" ? -1 : 1, + sortDirection: this.orderBy.direction, }, { arrayFormat: "comma", diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 56d17f8b39..2d3b06f762 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -1,10 +1,11 @@ import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; import type { SlSelectEvent } from "@shoelace-style/shoelace"; -import { html, type PropertyValues, type TemplateResult } from "lit"; +import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; +import queryString from "query-string"; import type { SelectNewDialogEvent } from "."; @@ -13,8 +14,9 @@ import { ClipboardController } from "@/controllers/clipboard"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; import { RouteNamespace } from "@/routes"; -import type { PublicCollection } from "@/types/collection"; -import type { PublicOrgCollections } from "@/types/org"; +import type { APIPaginatedList, APISortQuery } from "@/types/api"; +import { CollectionAccess, type Collection } from "@/types/collection"; +import { SortDirection } from "@/types/utils"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { tw } from "@/utils/tailwind"; @@ -56,16 +58,13 @@ export class Dashboard extends BtrixElement { }; private readonly publicCollections = new Task(this, { - task: async ([slug, metrics]) => { - if (!slug) throw new Error("slug required"); + task: async ([orgId]) => { + if (!orgId) throw new Error("orgId required"); - if (!metrics) return undefined; - if (!metrics.publicCollectionsCount) return []; - - const collections = await this.fetchCollections({ slug }); + const collections = await this.getPublicCollections({ orgId }); return collections; }, - args: () => [this.orgSlugState, this.metrics] as const, + args: () => [this.orgId] as const, }); willUpdate(changedProperties: PropertyValues & Map) { @@ -334,15 +333,17 @@ export class Dashboard extends BtrixElement { ${msg("Copy Link to Profile")} ` - : html` - - - - ${msg("Update Org Visibility")} - - `, + : this.appState.isAdmin + ? html` + + + + ${msg("Update Org Profile")} + + ` + : nothing, )} @@ -368,29 +369,16 @@ export class Dashboard extends BtrixElement { let button: TemplateResult; if (this.metrics.collectionsCount) { - if (this.org.enablePublicProfile) { - button = html` - { - this.navigate.to(`${this.navigate.orgBasePath}/collections`); - }} - > - - ${msg("Manage Collections")} - - `; - } else { - button = html` - { - this.navigate.to(`${this.navigate.orgBasePath}/settings`); - }} - > - - ${msg("Update Org Visibility")} - - `; - } + button = html` + { + this.navigate.to(`${this.navigate.orgBasePath}/collections`); + }} + > + + ${msg("Manage Collections")} + + `; } else { button = html` { - const resp = await fetch(`/api/public/orgs/${slug}/collections`, { - headers: { "Content-Type": "application/json" }, - }); - - switch (resp.status) { - case 200: - return ((await resp.json()) as PublicOrgCollections).collections; - case 404: - return []; - default: - throw resp.status; - } + private async getPublicCollections({ orgId }: { orgId: string }) { + const params: APISortQuery & { + access: CollectionAccess; + } = { + sortBy: "dateLatest", + sortDirection: SortDirection.Descending, + access: CollectionAccess.Public, + }; + const query = queryString.stringify(params); + + const data = await this.api.fetch>( + `/orgs/${orgId}/collections?${query}`, + ); + + return data.items; } } diff --git a/frontend/src/pages/org/profile.ts b/frontend/src/pages/org/profile.ts index fdcbb73a10..7dc9936fb0 100644 --- a/frontend/src/pages/org/profile.ts +++ b/frontend/src/pages/org/profile.ts @@ -3,10 +3,14 @@ import { Task } from "@lit/task"; import { html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; +import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import { page, pageHeading } from "@/layouts/page"; +import type { APIPaginatedList, APISortQuery } from "@/types/api"; +import { CollectionAccess, type Collection } from "@/types/collection"; import type { OrgData, PublicOrgCollections } from "@/types/org"; +import { SortDirection } from "@/types/utils"; @localized() @customElement("btrix-org-profile") @@ -242,7 +246,13 @@ export class OrgProfile extends BtrixElement { }: { slug: string; }): Promise { - const resp = await fetch(`/api/public/orgs/${slug}/collections`, { + const params: APISortQuery = { + sortBy: "dateLatest", + sortDirection: SortDirection.Descending, + }; + const query = queryString.stringify(params); + + const resp = await fetch(`/api/public/orgs/${slug}/collections?${query}`, { headers: { "Content-Type": "application/json" }, }); @@ -277,6 +287,9 @@ export class OrgProfile extends BtrixElement { } const org = await this.api.fetch(`/orgs/${userOrg.id}`); + const collections = await this.getUserPublicCollections({ + orgId: this.orgId, + }); return { org: { @@ -285,10 +298,27 @@ export class OrgProfile extends BtrixElement { url: org.publicUrl || "", verified: false, // TODO }, - collections: [], // TODO + collections, }; } catch { return null; } } + + private async getUserPublicCollections({ orgId }: { orgId: string }) { + const params: APISortQuery & { + access: CollectionAccess; + } = { + sortBy: "dateLatest", + sortDirection: SortDirection.Descending, + access: CollectionAccess.Public, + }; + const query = queryString.stringify(params); + + const data = await this.api.fetch>( + `/orgs/${orgId}/collections?${query}`, + ); + + return data.items; + } } diff --git a/frontend/src/strings/ui.ts b/frontend/src/strings/ui.ts index 95c560639a..5a426397b6 100644 --- a/frontend/src/strings/ui.ts +++ b/frontend/src/strings/ui.ts @@ -1,6 +1,9 @@ import { msg } from "@lit/localize"; import { html, type TemplateResult } from "lit"; +export const noData = "--"; +export const notApplicable = msg("n/a"); + // TODO Refactor all generic confirmation messages to use utility export const deleteConfirmation = (name: string | TemplateResult) => msg(html` diff --git a/frontend/src/strings/utils.ts b/frontend/src/strings/utils.ts new file mode 100644 index 0000000000..46ad5e7a5c --- /dev/null +++ b/frontend/src/strings/utils.ts @@ -0,0 +1,25 @@ +import { msg, str } from "@lit/localize"; + +import { noData } from "@/strings/ui"; +import localize from "@/utils/localize"; + +export const monthYearDateRange = ( + startDate?: string | null, + endDate?: string | null, +): string => { + if (!startDate || !endDate) { + return noData; + } + const format: Intl.DateTimeFormatOptions = { + month: "long", + year: "numeric", + }; + const startMonthYear = localize.date(startDate, format); + const endMonthYear = localize.date(endDate, format); + + if (startMonthYear === endMonthYear) return endMonthYear; + + return msg(str`${startMonthYear} to ${endMonthYear}`, { + desc: "Date range formatted to show full month name and year", + }); +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b016944aad..173d406562 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -30,7 +30,7 @@ export type APIPaginationQuery = { pageSize?: number; }; -export type APISortQuery = { - sortBy?: string; +export type APISortQuery> = { + sortBy?: keyof T; sortDirection?: SortDirection; }; diff --git a/frontend/src/types/collection.ts b/frontend/src/types/collection.ts index 1c0887bd52..f3d6899a57 100644 --- a/frontend/src/types/collection.ts +++ b/frontend/src/types/collection.ts @@ -11,6 +11,8 @@ export const publicCollectionSchema = z.object({ slug: z.string(), oid: z.string(), name: z.string(), + created: z.string().datetime(), + modified: z.string().datetime(), caption: z.string().nullable(), description: z.string().nullable(), resources: z.array(z.string()), @@ -25,6 +27,7 @@ export const publicCollectionSchema = z.object({ defaultThumbnailName: z.string().nullable(), crawlCount: z.number(), pageCount: z.number(), + snapshotCount: z.number(), totalSize: z.number(), allowPublicDownload: z.boolean(), homeUrl: z.string().url().nullable(), @@ -34,9 +37,6 @@ export const publicCollectionSchema = z.object({ export type PublicCollection = z.infer; export const collectionSchema = publicCollectionSchema.extend({ - id: z.string(), - created: z.string().datetime(), - modified: z.string().datetime(), tags: z.array(z.string()), access: z.nativeEnum(CollectionAccess), }); diff --git a/frontend/src/types/utils.ts b/frontend/src/types/utils.ts index d65cc73589..3d15b69317 100644 --- a/frontend/src/types/utils.ts +++ b/frontend/src/types/utils.ts @@ -22,5 +22,7 @@ export type Range = Exclude< Enumerate >; -/** 1 or -1, but will accept any number for easier typing where this is used **/ -export type SortDirection = -1 | 1 | (number & {}); +export enum SortDirection { + Descending = -1, + Ascending = 1, +} diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index ebbb592e87..29192e8e4a 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -91,6 +91,32 @@ const plurals = { id: "pages.plural.other", }), }, + snapshots: { + zero: msg("snapshots", { + desc: 'plural form of "snapshot" for zero snapshots', + id: "snapshots.plural.zero", + }), + one: msg("snapshot", { + desc: 'singular form for "snapshot"', + id: "snapshots.plural.one", + }), + two: msg("snapshots", { + desc: 'plural form of "snapshot" for two snapshots', + id: "snapshots.plural.two", + }), + few: msg("snapshots", { + desc: 'plural form of "snapshot" for few snapshots', + id: "snapshots.plural.few", + }), + many: msg("snapshots", { + desc: 'plural form of "snapshot" for many snapshots', + id: "snapshots.plural.many", + }), + other: msg("snapshots", { + desc: 'plural form of "snapshot" for multiple/other snapshots', + id: "snapshots.plural.other", + }), + }, comments: { zero: msg("comments", { desc: 'plural form of "comment" for zero comments',