@simmalugnt-se/payload-content-health 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ import type { AdminViewServerProps } from "payload";
2
+ import "../styles/view.css";
3
+ /**
4
+ * The work list: every finding with filters on check, collection and locale. Each row opens the
5
+ * document in the finding's locale with the field selected; where the editor assistant works, a
6
+ * second link opens it with a ready prompt too. Scans once per visit; the filters work in the browser.
7
+ * Only for visitors who may use Admin: Payload leaves custom views public.
8
+ */
9
+ export declare function ContentHealthView({ initPageResult, params, searchParams, }: AdminViewServerProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DefaultTemplate } from "@payloadcms/next/templates";
3
+ import { Gutter, SetStepNav } from "@payloadcms/ui";
4
+ import { redirect } from "next/navigation";
5
+ import { ASK, CHECK_LABELS, findingDetail, LIST_COPY, uiLanguage } from "../copy.js";
6
+ import { accessRedirect, assistantCollections, editURL, readFilters } from "../links.js";
7
+ import { scanContentHealth } from "../scan.js";
8
+ import { CHECK_IDS } from "../types.js";
9
+ import { WorkList } from "./WorkList.js";
10
+ import "../styles/view.css";
11
+ /**
12
+ * The work list: every finding with filters on check, collection and locale. Each row opens the
13
+ * document in the finding's locale with the field selected; where the editor assistant works, a
14
+ * second link opens it with a ready prompt too. Scans once per visit; the filters work in the browser.
15
+ * Only for visitors who may use Admin: Payload leaves custom views public.
16
+ */
17
+ export async function ContentHealthView({ initPageResult, params, searchParams, }) {
18
+ const { req, locale, permissions, visibleEntities } = initPageResult;
19
+ const denied = accessRedirect({
20
+ routes: { admin: req.payload.config.routes.admin, ...req.payload.config.admin.routes },
21
+ canAccessAdmin: Boolean(permissions?.canAccessAdmin),
22
+ signedIn: Boolean(req.user),
23
+ searchParams,
24
+ });
25
+ if (denied) {
26
+ redirect(denied);
27
+ }
28
+ const language = uiLanguage(req.i18n?.language);
29
+ const copy = LIST_COPY[language];
30
+ const { config } = req.payload;
31
+ const adminRoute = config.routes.admin;
32
+ const result = await scanContentHealth({ payload: req.payload, user: req.user });
33
+ const withAssistant = new Set(assistantCollections(config.admin?.custom));
34
+ const rows = sortFindings(result.findings).map((finding, index) => {
35
+ const detail = findingDetail(language, finding);
36
+ return {
37
+ key: `${index}:${finding.collection}:${finding.id}:${finding.check}:${finding.path}:${finding.locale ?? ""}`,
38
+ check: finding.check,
39
+ checkLabel: CHECK_LABELS[language][finding.check],
40
+ collection: finding.collection,
41
+ title: finding.title,
42
+ draft: finding.status === "draft",
43
+ href: editURL(adminRoute, finding),
44
+ ...(finding.locale ? { locale: finding.locale } : {}),
45
+ ...(detail ? { detail } : {}),
46
+ ...(withAssistant.has(finding.collection)
47
+ ? { askHref: editURL(adminRoute, finding, ASK[language][finding.check](finding)) }
48
+ : {}),
49
+ };
50
+ });
51
+ const checks = result.checks.map((check) => ({
52
+ value: check,
53
+ label: CHECK_LABELS[language][check],
54
+ }));
55
+ const collections = Object.keys(result.documents).map((slug) => ({
56
+ value: slug,
57
+ label: label(req.payload.collections[slug]?.config.labels?.plural, language) ?? slug,
58
+ }));
59
+ const locales = config.localization
60
+ ? config.localization.locales.map((locale) => ({
61
+ value: locale.code,
62
+ label: label(locale.label, language) ?? locale.code,
63
+ }))
64
+ : [];
65
+ // Payload wraps only its own views in the Admin shell; a custom view brings nav and header itself.
66
+ return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs(Gutter, { className: "content-health-list", children: [_jsx(SetStepNav, { nav: [{ label: copy.title }] }), _jsxs("header", { className: "content-health-list__header", children: [_jsx("h1", { children: copy.title }), _jsx("p", { className: "content-health-list__intro", children: copy.intro })] }), _jsx(WorkList, { checks: checks, collections: collections, initialFilters: readFilters(searchParams), language: language, locales: locales, rows: rows }), result.problems.length ? (_jsx("ul", { className: "content-health-list__problems", children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })) : null] }) }));
67
+ }
68
+ /** By check, then document, then locale, so a document's locales sit together. */
69
+ function sortFindings(findings) {
70
+ return [...findings].sort((a, b) => CHECK_IDS.indexOf(a.check) - CHECK_IDS.indexOf(b.check) ||
71
+ a.collection.localeCompare(b.collection) ||
72
+ a.title.localeCompare(b.title) ||
73
+ String(a.id).localeCompare(String(b.id)) ||
74
+ a.path.localeCompare(b.path) ||
75
+ (a.locale ?? "").localeCompare(b.locale ?? ""));
76
+ }
77
+ /** A config label: a string, or one per language. Functions are left to Payload. */
78
+ function label(value, language) {
79
+ if (typeof value === "string") {
80
+ return value;
81
+ }
82
+ if (value && typeof value === "object") {
83
+ const labels = value;
84
+ const text = labels[language] ?? labels.en ?? Object.values(labels)[0];
85
+ return typeof text === "string" ? text : undefined;
86
+ }
87
+ return undefined;
88
+ }
@@ -0,0 +1,34 @@
1
+ import { type UiLanguage } from "../copy.ts";
2
+ import { type ListFilters } from "../links.ts";
3
+ import type { CheckId } from "../types.ts";
4
+ export type WorkListOption = {
5
+ value: string;
6
+ label: string;
7
+ };
8
+ /** One finding, ready to show: labels and links are made on the server. */
9
+ export type WorkListRow = {
10
+ key: string;
11
+ check: CheckId;
12
+ checkLabel: string;
13
+ collection: string;
14
+ title: string;
15
+ locale?: string;
16
+ detail?: string;
17
+ draft: boolean;
18
+ /** The document in the finding's locale with the field selected. */
19
+ href: string;
20
+ /** The same with a prompt for the editor assistant; only where the assistant works. */
21
+ askHref?: string;
22
+ };
23
+ /**
24
+ * The findings with filters on check, collection and locale. Filters apply in the browser and are
25
+ * kept in the address, so the view can be linked to and reloaded with them.
26
+ */
27
+ export declare function WorkList({ checks, collections, initialFilters, language, locales, rows, }: {
28
+ checks: WorkListOption[];
29
+ collections: WorkListOption[];
30
+ initialFilters: ListFilters;
31
+ language: UiLanguage;
32
+ locales: WorkListOption[];
33
+ rows: WorkListRow[];
34
+ }): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Link } from "@payloadcms/ui";
4
+ import { useMemo, useState } from "react";
5
+ import { LIST_COPY } from "../copy.js";
6
+ import { filterQuery, matchesFilters } from "../links.js";
7
+ /**
8
+ * The findings with filters on check, collection and locale. Filters apply in the browser and are
9
+ * kept in the address, so the view can be linked to and reloaded with them.
10
+ */
11
+ export function WorkList({ checks, collections, initialFilters, language, locales, rows, }) {
12
+ const copy = LIST_COPY[language];
13
+ const [filters, setFilters] = useState(initialFilters);
14
+ const shown = useMemo(() => rows.filter((row) => matchesFilters(row, filters)), [rows, filters]);
15
+ const documents = new Set(shown.map((row) => row.href.split("?")[0])).size;
16
+ const filtered = Boolean(filters.check || filters.collection || filters.locale);
17
+ const update = (next) => {
18
+ setFilters(next);
19
+ const query = filterQuery(next);
20
+ window.history.replaceState(window.history.state, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
21
+ };
22
+ return (_jsxs("div", { className: "content-health-list__body", children: [_jsxs("div", { className: "content-health-list__filters", children: [_jsx(Filter, { label: copy.check, all: copy.all, onChange: (check) => update({ ...filters, check: check }), options: checks, value: filters.check }), collections.length > 1 ? (_jsx(Filter, { label: copy.collection, all: copy.all, onChange: (collection) => update({ ...filters, collection }), options: collections, value: filters.collection })) : null, locales.length > 1 ? (_jsx(Filter, { label: copy.locale, all: copy.all, onChange: (locale) => update({ ...filters, locale }), options: locales, value: filters.locale })) : null, filtered ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => update({}), size: "small", children: copy.clear })) : null] }), _jsx("p", { className: "content-health-list__summary", "aria-live": "polite", children: shown.length ? copy.summary(shown.length, documents) : null }), shown.length ? (_jsx("ul", { className: "content-health-list__rows", children: shown.map((row) => (_jsxs("li", { className: "content-health-list__row", children: [_jsxs("div", { className: "content-health-list__what", children: [_jsx("span", { className: "content-health-list__check", children: row.checkLabel }), row.detail ? (_jsx("span", { className: "content-health-list__detail", children: row.detail })) : null] }), _jsxs("div", { className: "content-health-list__document", children: [_jsx(Link, { href: row.href, prefetch: false, children: row.title }), row.locale ? (_jsx("span", { className: "content-health-list__pill", children: row.locale })) : null, row.draft ? (_jsx("span", { className: "content-health-list__pill content-health-list__pill--draft", children: copy.draft })) : null] }), _jsx("div", { className: "content-health-list__actions", children: row.askHref ? (_jsx(Button, { buttonStyle: "secondary", el: "link", margin: false, size: "small", url: row.askHref, children: copy.fix })) : null })] }, row.key))) })) : (_jsx("p", { className: "content-health-list__empty", children: filtered ? copy.noneFiltered : copy.none }))] }));
23
+ }
24
+ function Filter({ all, label, onChange, options, value, }) {
25
+ return (_jsxs("label", { className: "content-health-list__filter", children: [_jsx("span", { children: label }), _jsxs("select", { onChange: (event) => onChange(event.target.value || undefined), value: value ?? "", children: [_jsx("option", { value: "", children: all }), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })] }));
26
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { formatAdminURL } from "payload/shared";
3
- import { COPY, STATUS, TILE_LABELS, uiLanguage } from "../copy.js";
2
+ import { COPY, findingDetail, STATUS, TILE_LABELS, uiLanguage } from "../copy.js";
4
3
  import { checkStats, countDocuments } from "../group.js";
4
+ import { editURL, listURL } from "../links.js";
5
5
  import { scanContentHealth } from "../scan.js";
6
6
  import { CHECK_IDS } from "../types.js";
7
7
  import { Ring } from "./Ring.js";
@@ -39,23 +39,18 @@ function CheckTile({ adminRoute, language, stat, }) {
39
39
  : stat.failing
40
40
  ? STATUS[language][stat.check](stat.checked - stat.failing, stat.checked)
41
41
  : copy.checkPasses;
42
- return (_jsxs("li", { className: "content-health__tile", children: [_jsxs("div", { className: "content-health__tile-head", children: [_jsx(Ring, { className: "content-health__ring", failing: stat.failing, label: status, total: stat.checked }), _jsxs("div", { children: [_jsx("h3", { className: "content-health__tile-title", children: TILE_LABELS[language][stat.check] }), _jsx("p", { className: "content-health__tile-status", children: status })] })] }), stat.documents.length ? (_jsxs("ul", { className: "content-health__rows", children: [stat.documents.slice(0, ROWS_PER_CHECK).map((row) => (_jsxs("li", { className: "content-health__row", children: [_jsx("a", { href: editURL(adminRoute, row.findings[0]), children: row.title }), row.findings.map((finding) => (_jsxs("span", { className: "content-health__locale", children: [finding.locale ? (_jsx("a", { className: "content-health__pill", href: editURL(adminRoute, finding), children: finding.locale })) : null, _jsx(Detail, { copy: copy, finding: finding })] }, finding.locale ?? "")))] }, `${row.collection}:${row.id}`))), stat.documents.length > ROWS_PER_CHECK ? (_jsx("li", { className: "content-health__more", children: copy.more(stat.documents.length - ROWS_PER_CHECK) })) : null] })) : null] }));
42
+ return (_jsxs("li", { className: "content-health__tile", children: [_jsxs("div", { className: "content-health__tile-head", children: [_jsx(Ring, { className: "content-health__ring", failing: stat.failing, label: status, total: stat.checked }), _jsxs("div", { children: [_jsx("h3", { className: "content-health__tile-title", children: TILE_LABELS[language][stat.check] }), _jsx("p", { className: "content-health__tile-status", children: status })] })] }), stat.documents.length ? (_jsxs("ul", { className: "content-health__rows", children: [stat.documents.slice(0, ROWS_PER_CHECK).map((row) => (_jsx(FindingRow, { adminRoute: adminRoute, language: language, row: row }, `${row.collection}:${row.id}`))), stat.documents.length > ROWS_PER_CHECK ? (_jsx("li", { className: "content-health__more", children: _jsx("a", { href: listURL(adminRoute, { check: stat.check }), children: copy.more(stat.documents.length - ROWS_PER_CHECK) }) })) : null] })) : null] }));
43
43
  }
44
- function Detail({ copy, finding }) {
45
- const { heading, length, words } = finding;
46
- const text = length
47
- ? copy.length(length.actual, length.min, length.max)
48
- : words
49
- ? copy.words(words.actual, words.min)
50
- : heading?.previous !== undefined
51
- ? copy.headingSkip(heading.previous, heading.level)
52
- : heading?.count
53
- ? copy.h1Count(heading.count)
54
- : undefined;
55
- return text ? _jsx("span", { className: "content-health__detail", children: text }) : null;
56
- }
57
- function editURL(adminRoute, finding) {
58
- const path = `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
59
- const url = formatAdminURL({ adminRoute, path });
60
- return finding.locale ? `${url}?locale=${encodeURIComponent(finding.locale)}` : url;
44
+ /**
45
+ * A document on its own line, then its locales: one per line when they carry details (lengths, word
46
+ * counts), otherwise side by side.
47
+ */
48
+ function FindingRow({ adminRoute, language, row, }) {
49
+ const locales = row.findings.map((finding) => ({
50
+ finding,
51
+ detail: findingDetail(language, finding),
52
+ }));
53
+ const stacked = locales.some(({ detail }) => detail);
54
+ const shown = locales.filter(({ finding, detail }) => finding.locale || detail);
55
+ return (_jsxs("li", { className: "content-health__row", children: [_jsx("a", { href: editURL(adminRoute, row.findings[0]), children: row.title }), shown.length ? (_jsx("ul", { className: `content-health__locales${stacked ? " content-health__locales--stacked" : ""}`, children: shown.map(({ finding, detail }) => (_jsxs("li", { className: "content-health__locale", children: [finding.locale ? (_jsx("a", { className: "content-health__pill", href: editURL(adminRoute, finding), children: finding.locale })) : null, detail ? _jsx("span", { className: "content-health__detail", children: detail }) : null] }, finding.locale ?? ""))) })) : null] }));
61
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-content-health",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Content health checks and dashboard widgets for Payload Admin: alt texts, SEO fields and more",
5
5
  "keywords": [
6
6
  "payload",
@@ -34,20 +34,34 @@
34
34
  "import": "./dist/exports/rsc.js",
35
35
  "types": "./dist/exports/rsc.d.ts",
36
36
  "default": "./dist/exports/rsc.js"
37
+ },
38
+ "./client": {
39
+ "import": "./dist/exports/client.js",
40
+ "types": "./dist/exports/client.d.ts",
41
+ "default": "./dist/exports/client.js"
37
42
  }
38
43
  },
39
44
  "publishConfig": {
40
45
  "access": "public"
41
46
  },
42
47
  "peerDependencies": {
48
+ "@payloadcms/next": ">=3.90.2 <4",
49
+ "@payloadcms/ui": ">=3.90.2 <4",
50
+ "next": ">=16.2.6 <17",
43
51
  "payload": ">=3.90.2 <4",
44
- "react": "^19.0.0"
52
+ "react": "^19.0.0",
53
+ "react-dom": "^19.0.0"
45
54
  },
46
55
  "devDependencies": {
56
+ "@payloadcms/next": "3.90.2",
57
+ "@payloadcms/ui": "3.90.2",
47
58
  "@types/node": "^22",
48
59
  "@types/react": "^19.2.18",
60
+ "@types/react-dom": "^19.2.4",
61
+ "next": "16.3.6",
49
62
  "payload": "3.90.2",
50
63
  "react": "19.2.8",
64
+ "react-dom": "19.2.8",
51
65
  "tsx": "^4.21.0",
52
66
  "typescript": "5.9.3"
53
67
  },