Allow showing all collections as well as just public ones in org dashboard (#2379)
Adds a switch to switch between viewing public collections only (default) and all collections on org dashboard. Also updates the `house-fill` icon to `house` in a couple places (@Shrinks99) --------- Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
This commit is contained in:
parent
4516268a70
commit
0f2da4f785
@ -1,11 +1,17 @@
|
||||
import { localized, msg } from "@lit/localize";
|
||||
import clsx from "clsx";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
queryAssignedNodes,
|
||||
state,
|
||||
} from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { when } from "lit/directives/when.js";
|
||||
|
||||
import { CollectionThumbnail } from "./collection-thumbnail";
|
||||
import { SelectCollectionAccess } from "./select-collection-access";
|
||||
|
||||
import { BtrixElement } from "@/classes/BtrixElement";
|
||||
import { RouteNamespace } from "@/routes";
|
||||
@ -32,6 +38,12 @@ export class CollectionsGrid extends BtrixElement {
|
||||
@property({ type: String })
|
||||
collectionRefreshing: string | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showVisibility = false;
|
||||
|
||||
@queryAssignedNodes({ slot: "pagination" })
|
||||
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`;
|
||||
|
||||
@ -89,6 +101,16 @@ export class CollectionsGrid extends BtrixElement {
|
||||
${this.renderDateBadge(collection)}
|
||||
</div>
|
||||
<div class="${showActions ? "mr-9" : ""} min-h-9 leading-tight">
|
||||
${this.showVisibility
|
||||
? html`<sl-icon
|
||||
class="mr-[5px] align-[-1px] text-sm"
|
||||
name=${SelectCollectionAccess.Options[collection.access]
|
||||
.icon}
|
||||
label=${SelectCollectionAccess.Options[
|
||||
collection.access
|
||||
].label}
|
||||
></sl-icon>`
|
||||
: nothing}
|
||||
<strong
|
||||
class="text-base font-medium leading-tight text-stone-700 transition-colors group-hover:text-cyan-600"
|
||||
>
|
||||
@ -118,6 +140,12 @@ export class CollectionsGrid extends BtrixElement {
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<slot
|
||||
class=${clsx("justify-center flex", this.pagination.length && "mt-10")}
|
||||
name="pagination"
|
||||
></slot>
|
||||
|
||||
${when(
|
||||
showActions,
|
||||
() =>
|
||||
|
@ -257,10 +257,7 @@ export class CollectionDetail extends BtrixElement {
|
||||
${!this.collection ||
|
||||
Boolean(this.collection.crawlCount && !this.isRwpLoaded)
|
||||
? html`<sl-spinner slot="prefix"></sl-spinner>`
|
||||
: html`<sl-icon
|
||||
name="house-fill"
|
||||
slot="prefix"
|
||||
></sl-icon>`}
|
||||
: html`<sl-icon name="house" slot="prefix"></sl-icon>`}
|
||||
${msg("Set Initial View")}
|
||||
</sl-button>
|
||||
</sl-tooltip>
|
||||
@ -522,7 +519,7 @@ export class CollectionDetail extends BtrixElement {
|
||||
${!this.collection ||
|
||||
Boolean(this.collection.crawlCount && !this.isRwpLoaded)
|
||||
? html`<sl-spinner slot="prefix"></sl-spinner>`
|
||||
: html`<sl-icon name="house-fill" slot="prefix"></sl-icon>`}
|
||||
: html`<sl-icon name="house" slot="prefix"></sl-icon>`}
|
||||
${msg("Set Initial View")}
|
||||
</sl-menu-item>
|
||||
</sl-tooltip>
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { localized, msg } from "@lit/localize";
|
||||
import { Task } from "@lit/task";
|
||||
import type { SlSelectEvent } from "@shoelace-style/shoelace";
|
||||
import { Task, TaskStatus } from "@lit/task";
|
||||
import type {
|
||||
SlChangeEvent,
|
||||
SlRadioGroup,
|
||||
SlSelectEvent,
|
||||
} from "@shoelace-style/shoelace";
|
||||
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";
|
||||
@ -10,6 +14,7 @@ import queryString from "query-string";
|
||||
import type { SelectNewDialogEvent } from ".";
|
||||
|
||||
import { BtrixElement } from "@/classes/BtrixElement";
|
||||
import { type PageChangeEvent } from "@/components/ui/pagination";
|
||||
import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog";
|
||||
import { pageHeading } from "@/layouts/page";
|
||||
import { pageHeader } from "@/layouts/pageHeader";
|
||||
@ -19,6 +24,8 @@ import { CollectionAccess, type Collection } from "@/types/collection";
|
||||
import { SortDirection } from "@/types/utils";
|
||||
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
|
||||
import { tw } from "@/utils/tailwind";
|
||||
import { timeoutCache } from "@/utils/timeoutCache";
|
||||
import { cached } from "@/utils/weakCache";
|
||||
|
||||
type Metrics = {
|
||||
storageUsedBytes: number;
|
||||
@ -40,6 +47,13 @@ type Metrics = {
|
||||
publicCollectionsCount: number;
|
||||
};
|
||||
|
||||
enum CollectionGridView {
|
||||
All = "all",
|
||||
Public = "public",
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 16;
|
||||
|
||||
@customElement("btrix-dashboard")
|
||||
@localized()
|
||||
export class Dashboard extends BtrixElement {
|
||||
@ -52,6 +66,15 @@ export class Dashboard extends BtrixElement {
|
||||
@state()
|
||||
collectionRefreshing: string | null = null;
|
||||
|
||||
@state()
|
||||
collectionsView = CollectionGridView.Public;
|
||||
|
||||
@state()
|
||||
collectionPage = 1;
|
||||
|
||||
// Used for busting cache when updating visible collection
|
||||
cacheBust = 0;
|
||||
|
||||
private readonly colors = {
|
||||
default: "neutral",
|
||||
crawls: "green",
|
||||
@ -60,15 +83,31 @@ export class Dashboard extends BtrixElement {
|
||||
runningTime: "blue",
|
||||
};
|
||||
|
||||
private readonly publicCollections = new Task(this, {
|
||||
task: async ([orgId]) => {
|
||||
if (!orgId) throw new Error("orgId required");
|
||||
private readonly collections = new Task(this, {
|
||||
task: cached(
|
||||
async ([orgId, collectionsView, collectionPage]) => {
|
||||
if (!orgId) throw new Error("orgId required");
|
||||
|
||||
const collections = await this.getPublicCollections({ orgId });
|
||||
this.collectionRefreshing = null;
|
||||
return collections;
|
||||
},
|
||||
args: () => [this.orgId] as const,
|
||||
const collections = await this.getCollections({
|
||||
orgId,
|
||||
access:
|
||||
collectionsView === CollectionGridView.Public
|
||||
? CollectionAccess.Public
|
||||
: undefined,
|
||||
page: collectionPage,
|
||||
});
|
||||
this.collectionRefreshing = null;
|
||||
return collections;
|
||||
},
|
||||
{ cacheConstructor: timeoutCache(300) },
|
||||
),
|
||||
args: () =>
|
||||
[
|
||||
this.orgId,
|
||||
this.collectionsView,
|
||||
this.collectionPage,
|
||||
this.cacheBust,
|
||||
] as const,
|
||||
});
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this> & Map<string, unknown>) {
|
||||
@ -330,17 +369,28 @@ export class Dashboard extends BtrixElement {
|
||||
|
||||
<section class="mb-16">
|
||||
<header class="mb-1.5 flex items-center justify-between">
|
||||
${pageHeading({
|
||||
content: msg("Public Collections"),
|
||||
})}
|
||||
<div class="flex items-center gap-2">
|
||||
${when(
|
||||
this.appState.isCrawler,
|
||||
() => html`
|
||||
<!-- TODO Refactor clipboard code, get URL in a nicer way? -->
|
||||
${when(this.org, (org) =>
|
||||
org.enablePublicProfile
|
||||
? html` <btrix-copy-button
|
||||
${pageHeading({
|
||||
content:
|
||||
this.collectionsView === CollectionGridView.Public
|
||||
? msg("Public Collections")
|
||||
: msg("All Collections"),
|
||||
})}
|
||||
${this.collectionsView === CollectionGridView.Public
|
||||
? html` <span class="text-sm text-neutral-400"
|
||||
>—
|
||||
<a
|
||||
href=${`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`}
|
||||
class="inline-flex h-8 items-center text-sm font-medium text-primary-500 transition hover:text-primary-600"
|
||||
@click=${this.navigate.link}
|
||||
>
|
||||
${this.org?.enablePublicProfile
|
||||
? msg("Visit public collections gallery")
|
||||
: msg("Preview public collections gallery")}
|
||||
</a>
|
||||
<!-- TODO Refactor clipboard code, get URL in a nicer way? -->
|
||||
${this.org?.enablePublicProfile
|
||||
? html`<btrix-copy-button
|
||||
value=${new URL(
|
||||
`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`,
|
||||
window.location.toString(),
|
||||
@ -348,9 +398,16 @@ export class Dashboard extends BtrixElement {
|
||||
content=${msg(
|
||||
"Copy Link to Public Collections Gallery",
|
||||
)}
|
||||
class="inline-block"
|
||||
></btrix-copy-button>`
|
||||
: nothing,
|
||||
)}
|
||||
: nothing}
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${when(
|
||||
this.appState.isCrawler,
|
||||
() => html`
|
||||
<sl-tooltip content=${msg("Manage Collections")}>
|
||||
<sl-icon-button
|
||||
href=${`${this.navigate.orgBasePath}/collections`}
|
||||
@ -361,32 +418,74 @@ export class Dashboard extends BtrixElement {
|
||||
</sl-tooltip>
|
||||
`,
|
||||
)}
|
||||
<sl-tooltip
|
||||
content=${this.org?.enablePublicProfile
|
||||
? msg("Visit Public Collections Gallery")
|
||||
: msg("Preview Public Collections Gallery")}
|
||||
|
||||
<sl-radio-group
|
||||
value=${this.collectionsView}
|
||||
size="small"
|
||||
@sl-change=${(e: SlChangeEvent) => {
|
||||
this.collectionPage = 1;
|
||||
this.collectionsView = (e.target as SlRadioGroup)
|
||||
.value as CollectionGridView;
|
||||
}}
|
||||
>
|
||||
<sl-icon-button
|
||||
href=${`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`}
|
||||
class="size-8 text-base"
|
||||
name="globe2"
|
||||
@click=${this.navigate.link}
|
||||
></sl-icon-button>
|
||||
</sl-tooltip>
|
||||
<sl-tooltip content=${msg("Public Collections")}>
|
||||
<sl-radio-button pill value=${CollectionGridView.Public}>
|
||||
<sl-icon
|
||||
name="globe"
|
||||
label=${msg("Public Collections")}
|
||||
></sl-icon> </sl-radio-button
|
||||
></sl-tooltip>
|
||||
<sl-tooltip content=${msg("All Collections")}>
|
||||
<sl-radio-button pill value=${CollectionGridView.All}>
|
||||
<sl-icon
|
||||
name="asterisk"
|
||||
label=${msg("All Collections")}
|
||||
></sl-icon> </sl-radio-button
|
||||
></sl-tooltip>
|
||||
</sl-radio-group>
|
||||
</div>
|
||||
</header>
|
||||
<div class="rounded-lg border p-10">
|
||||
<div class="relative rounded-lg border p-10">
|
||||
<btrix-collections-grid
|
||||
slug=${this.orgSlugState || ""}
|
||||
.collections=${this.publicCollections.value}
|
||||
.collections=${this.collections.value?.items}
|
||||
.collectionRefreshing=${this.collectionRefreshing}
|
||||
?showVisibility=${this.collectionsView === CollectionGridView.All}
|
||||
@btrix-collection-saved=${async (e: CollectionSavedEvent) => {
|
||||
this.collectionRefreshing = e.detail.id;
|
||||
void this.publicCollections.run([this.orgId]);
|
||||
void this.collections.run([
|
||||
this.orgId,
|
||||
this.collectionsView,
|
||||
this.collectionPage,
|
||||
++this.cacheBust,
|
||||
]);
|
||||
}}
|
||||
>
|
||||
${this.renderNoPublicCollections()}
|
||||
${this.collections.value &&
|
||||
this.collections.value.total > this.collections.value.items.length
|
||||
? html`
|
||||
<btrix-pagination
|
||||
page=${this.collectionPage}
|
||||
size=${PAGE_SIZE}
|
||||
totalCount=${this.collections.value.total}
|
||||
@page-change=${(e: PageChangeEvent) => {
|
||||
this.collectionPage = e.detail.page;
|
||||
}}
|
||||
slot="pagination"
|
||||
>
|
||||
</btrix-pagination>
|
||||
`
|
||||
: nothing}
|
||||
</btrix-collections-grid>
|
||||
${this.collections.status === TaskStatus.PENDING &&
|
||||
this.collections.value
|
||||
? html`<div
|
||||
class="absolute inset-0 rounded-lg bg-stone-50/75 p-24 text-center text-4xl"
|
||||
>
|
||||
<sl-spinner></sl-spinner>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@ -864,13 +963,25 @@ export class Dashboard extends BtrixElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPublicCollections({ orgId }: { orgId: string }) {
|
||||
private async getCollections({
|
||||
orgId,
|
||||
access,
|
||||
page,
|
||||
}: {
|
||||
orgId: string;
|
||||
access?: CollectionAccess;
|
||||
page?: number;
|
||||
}) {
|
||||
const params: APISortQuery<Collection> & {
|
||||
access: CollectionAccess;
|
||||
access?: CollectionAccess;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {
|
||||
sortBy: "dateLatest",
|
||||
sortDirection: SortDirection.Descending,
|
||||
access: CollectionAccess.Public,
|
||||
access,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const query = queryString.stringify(params);
|
||||
|
||||
@ -878,6 +989,6 @@ export class Dashboard extends BtrixElement {
|
||||
`/orgs/${orgId}/collections?${query}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
39
frontend/src/utils/timeoutCache.ts
Normal file
39
frontend/src/utils/timeoutCache.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { type Cache } from "./weakCache";
|
||||
|
||||
export function timeoutCache(seconds: number) {
|
||||
return class<K, V> implements Cache<K, V> {
|
||||
private readonly cache: { [key: string]: V } = Object.create(null);
|
||||
set(key: K | string, value: V) {
|
||||
if (typeof key !== "string") {
|
||||
key = JSON.stringify(key);
|
||||
}
|
||||
this.cache[key] = value;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
delete this.cache[key as string];
|
||||
} catch (_) {
|
||||
/* empty */
|
||||
console.debug("missing key", key);
|
||||
}
|
||||
}, seconds * 1000);
|
||||
return this;
|
||||
}
|
||||
get(key: K | string) {
|
||||
if (typeof key !== "string") {
|
||||
key = JSON.stringify(key);
|
||||
}
|
||||
try {
|
||||
return this.cache[key];
|
||||
} catch (_) {
|
||||
console.debug("missing key", key);
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
has(key: K | string) {
|
||||
if (typeof key !== "string") {
|
||||
key = JSON.stringify(key);
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(this.cache, key);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,3 +1,14 @@
|
||||
export interface Cache<K, V> {
|
||||
set: (key: K, value: V) => this;
|
||||
get: (key: K) => V | undefined;
|
||||
has: (key: K) => boolean;
|
||||
}
|
||||
|
||||
export interface CacheConstructor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
new <K, V>(...args: any[]): Cache<K, V>;
|
||||
}
|
||||
|
||||
export const WeakRefMapInnerValue = Symbol("inner value");
|
||||
|
||||
type WeakRefValue<T> = T extends object
|
||||
@ -28,16 +39,16 @@ const unwrapValue = <V>(val: WeakRef<WeakRefValue<V>> | undefined) => {
|
||||
*
|
||||
* Adapted from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management#weakrefs_and_finalizationregistry and https://stackoverflow.com/a/72896692
|
||||
*/
|
||||
export class WeakRefMap<K, V> {
|
||||
export class WeakRefMap<K, V> implements Cache<K, V> {
|
||||
readonly cacheMap = new Map<K, WeakRef<WeakRefValue<V>>>();
|
||||
private readonly finalizer = new FinalizationRegistry((key: K) => {
|
||||
this.cacheMap.delete(key);
|
||||
});
|
||||
|
||||
set(key: K, value: V): V {
|
||||
set(key: K, value: V): this {
|
||||
const cache = this.get(key);
|
||||
if (cache) {
|
||||
if (cache === value) return value;
|
||||
if (cache === value) return this;
|
||||
if (typeof cache === "object") {
|
||||
this.finalizer.unregister(cache);
|
||||
}
|
||||
@ -47,14 +58,14 @@ export class WeakRefMap<K, V> {
|
||||
this.cacheMap.set(key, ref);
|
||||
this.finalizer.register(objVal, key, objVal);
|
||||
|
||||
return isWrapped(objVal) ? objVal[WeakRefMapInnerValue] : (objVal as V);
|
||||
return this;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
get(key: K) {
|
||||
return unwrapValue(this.cacheMap.get(key));
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
has(key: K) {
|
||||
return this.cacheMap.has(key);
|
||||
}
|
||||
}
|
||||
@ -67,13 +78,20 @@ export function cached<
|
||||
Serializer extends (args: Args) => unknown = (args: Args) => string,
|
||||
>(
|
||||
fn: (...args: Args) => Result,
|
||||
serializer: Serializer = JSON.stringify as Serializer,
|
||||
options: {
|
||||
cacheConstructor?: CacheConstructor;
|
||||
serializer?: Serializer;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
serializer = JSON.stringify as Serializer,
|
||||
cacheConstructor = WeakRefMap,
|
||||
} = options;
|
||||
type Key = ReturnType<Serializer>;
|
||||
const cache = new WeakRefMap<Key, Result>();
|
||||
const cache = new cacheConstructor<Key, Result>();
|
||||
const cachedFn: {
|
||||
(...args: Args): Result;
|
||||
[InnerCache]: WeakRefMap<Key, Result>;
|
||||
[InnerCache]: Cache<Key, Result>;
|
||||
} = (...args: Args) => {
|
||||
let k;
|
||||
try {
|
||||
@ -83,7 +101,13 @@ export function cached<
|
||||
"Unable to serialize function arguments successfully - ensure your serializer function is able to handle the args you're passing in",
|
||||
);
|
||||
}
|
||||
return cache.get(k) ?? cache.set(k, fn(...args));
|
||||
if (cache.has(k)) {
|
||||
return cache.get(k)!;
|
||||
} else {
|
||||
const v = fn(...args);
|
||||
cache.set(k, v);
|
||||
return v;
|
||||
}
|
||||
};
|
||||
cachedFn[InnerCache] = cache;
|
||||
return cachedFn;
|
||||
|
Loading…
Reference in New Issue
Block a user