feat: Show additional collection details (#2455)

Resolves https://github.com/webrecorder/browsertrix/issues/2452

## Changes

- Displays page count and collection size in listing grid
- Displays month if collection period is in the same year
- Displays collection size in About > Details section
- Minor refactor: move byte formatting into `localize.ts` utility file,
move slash (`/`) separator into own utility file
This commit is contained in:
sua yoo 2025-03-03 13:15:27 -08:00 committed by GitHub
parent e13c3bfb48
commit 65a40c4816
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 48 deletions

View File

@ -33,35 +33,5 @@ export class LocalizeController extends SlLocalizeController {
return localize.duration(duration);
};
/**
* From https://github.com/shoelace-style/shoelace/blob/v2.18.0/src/components/format-bytes/format-bytes.component.ts
*/
readonly bytes = (value: number, options?: Intl.NumberFormatOptions) => {
if (isNaN(value)) {
return "";
}
const opts: Intl.NumberFormatOptions = {
unit: "byte",
unitDisplay: "short",
...options,
};
const bitPrefixes = ["", "kilo", "mega", "giga", "tera"]; // petabit isn't a supported unit
const bytePrefixes = ["", "kilo", "mega", "giga", "tera", "peta"];
const prefix = opts.unit === "bit" ? bitPrefixes : bytePrefixes;
const index = Math.max(
0,
Math.min(Math.floor(Math.log10(value) / 3), prefix.length - 1),
);
const unit = prefix[index] + opts.unit;
const valueToFormat = parseFloat(
(value / Math.pow(1000, index)).toPrecision(3),
);
return localize.number(valueToFormat, {
style: "unit",
unit,
unitDisplay: opts.unitDisplay,
});
};
readonly bytes = localize.bytes;
}

View File

@ -14,8 +14,10 @@ import { CollectionThumbnail } from "./collection-thumbnail";
import { SelectCollectionAccess } from "./select-collection-access";
import { BtrixElement } from "@/classes/BtrixElement";
import { textSeparator } from "@/layouts/separator";
import { RouteNamespace } from "@/routes";
import type { PublicCollection } from "@/types/collection";
import { pluralOf } from "@/utils/pluralize";
import { tw } from "@/utils/tailwind";
/**
@ -45,7 +47,7 @@ export class CollectionsGrid extends BtrixElement {
pagination!: Node[];
render() {
const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`;
const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3`;
if (!this.collections || !this.slug) {
const thumb = html`
@ -114,14 +116,22 @@ export class CollectionsGrid extends BtrixElement {
></sl-icon>`
: nothing}
<strong
class="text-base font-medium leading-tight text-stone-700 transition-colors group-hover:text-cyan-600"
class="text-base font-medium leading-tight text-stone-800 transition-colors group-hover:text-cyan-600"
>
${collection.name}
</strong>
<div class="mt-1.5 flex gap-2 leading-tight text-stone-400">
<div>
${this.localize.number(collection.pageCount)}
${pluralOf("pages", collection.pageCount)}
</div>
${textSeparator()}
<div>${this.localize.bytes(collection.totalSize)}</div>
</div>
${collection.caption &&
html`
<p
class="mt-1.5 text-pretty leading-relaxed text-stone-500 transition-colors group-hover:text-cyan-600"
class="mt-1.5 text-pretty leading-relaxed text-stone-500"
>
${collection.caption}
</p>
@ -180,7 +190,7 @@ export class CollectionsGrid extends BtrixElement {
</div>
`;
private renderDateBadge(collection: PublicCollection) {
renderDateBadge(collection: PublicCollection) {
if (!collection.dateEarliest || !collection.dateLatest) return;
const earliestYear = this.localize.date(collection.dateEarliest, {
@ -190,10 +200,32 @@ export class CollectionsGrid extends BtrixElement {
year: "numeric",
});
let date = "";
if (earliestYear === latestYear) {
const earliestMonth = new Date(collection.dateEarliest).getMonth();
const latestMonth = new Date(collection.dateLatest).getMonth();
if (earliestMonth === latestMonth) {
date = this.localize.date(collection.dateEarliest, {
month: "long",
year: "numeric",
});
} else {
date = `${this.localize.date(collection.dateEarliest, {
month: "short",
})} ${this.localize.date(collection.dateLatest, {
month: "short",
year: "numeric",
})}`;
}
} else {
date = `${earliestYear} ${latestYear} `;
}
return html`
<btrix-badge variant="primary" class="absolute right-3 top-3">
${earliestYear}
${latestYear !== earliestYear ? html` ${latestYear} ` : nothing}
${date}
</btrix-badge>
`;
}

View File

@ -52,6 +52,10 @@ export function metadataColumn(collection?: Collection | PublicCollection) {
render: (col) =>
`${localize.number(col.pageCount)} ${pluralOf("pages", col.pageCount)}`,
})}
${metadataItem({
label: metadata.totalSize,
render: (col) => `${localize.bytes(col.totalSize)}`,
})}
</btrix-desc-list>
`;
}

View File

@ -4,6 +4,8 @@ import clsx from "clsx";
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { textSeparator } from "./separator";
import { NavigateController } from "@/controllers/navigate";
import { tw } from "@/utils/tailwind";
@ -26,15 +28,7 @@ function navigateBreadcrumb(e: MouseEvent, href: string) {
el.dispatchEvent(evt);
}
export function breadcrumbSeparator() {
return html`<span
class="font-mono font-thin text-neutral-400"
role="separator"
>/</span
> `;
}
const separator = breadcrumbSeparator();
const breadcrumbSeparator = textSeparator();
const skeleton = html`<sl-skeleton class="w-48"></sl-skeleton>`;
function breadcrumbLink({ href, content }: Breadcrumb, classNames?: string) {
@ -65,11 +59,11 @@ function pageBreadcrumbs(breadcrumbs: Breadcrumb[]) {
${breadcrumbs.length
? breadcrumbs.map(
(breadcrumb, i) => html`
${i !== 0 ? separator : nothing}
${i !== 0 ? breadcrumbSeparator : nothing}
${breadcrumbLink(breadcrumb, tw`max-w-[30ch]`)}
`,
)
: html`${skeleton} ${separator} ${skeleton}`}
: html`${skeleton} ${breadcrumbSeparator} ${skeleton}`}
</nav>
`;
}

View File

@ -0,0 +1,12 @@
import { html } from "lit";
/**
* For separatoring text in the same line, e.g. for breadcrumbs or item details
*/
export function textSeparator() {
return html`<span
class="font-mono font-thin text-neutral-400"
role="separator"
>/</span
> `;
}

View File

@ -4,4 +4,5 @@ export const metadata = {
dateLatest: msg("Collection Period"),
uniquePageCount: msg("Unique Pages in Collection"),
pageCount: msg("Total Pages Crawled"),
totalSize: msg("Collection Size"),
};

View File

@ -264,6 +264,38 @@ export class Localize {
const pluralRule = formatter.select(value);
return phrases[pluralRule];
};
/**
* From https://github.com/shoelace-style/shoelace/blob/v2.18.0/src/components/format-bytes/format-bytes.component.ts
*/
readonly bytes = (value: number, options?: Intl.NumberFormatOptions) => {
if (isNaN(value)) {
return "";
}
const opts: Intl.NumberFormatOptions = {
unit: "byte",
unitDisplay: "short",
...options,
};
const bitPrefixes = ["", "kilo", "mega", "giga", "tera"]; // petabit isn't a supported unit
const bytePrefixes = ["", "kilo", "mega", "giga", "tera", "peta"];
const prefix = opts.unit === "bit" ? bitPrefixes : bytePrefixes;
const index = Math.max(
0,
Math.min(Math.floor(Math.log10(value) / 3), prefix.length - 1),
);
const unit = prefix[index] + opts.unit;
const valueToFormat = parseFloat(
(value / Math.pow(1000, index)).toPrecision(3),
);
return localize.number(valueToFormat, {
style: "unit",
unit,
unitDisplay: opts.unitDisplay,
});
};
}
const localize = new Localize(sourceLocale);