@byline/host-tanstack-start 4.13.1 → 4.14.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.
- package/dist/admin-shell/chrome/admin-layout_module.css +1 -1
- package/dist/admin-shell/chrome/admin-menu-drawer_module.css +6 -5
- package/dist/admin-shell/chrome/dashboard.js +96 -68
- package/dist/admin-shell/chrome/dashboard.module.js +3 -1
- package/dist/admin-shell/chrome/dashboard_module.css +27 -1
- package/dist/integrations/abilities.d.ts +1 -0
- package/dist/integrations/abilities.js +2 -1
- package/dist/routes/create-admin-dashboard-route.js +7 -3
- package/package.json +11 -11
- package/src/admin-shell/chrome/admin-layout.module.css +6 -1
- package/src/admin-shell/chrome/admin-menu-drawer.module.css +12 -5
- package/src/admin-shell/chrome/dashboard.module.css +42 -2
- package/src/admin-shell/chrome/dashboard.tsx +116 -62
- package/src/integrations/abilities.tsx +2 -1
- package/src/routes/create-admin-dashboard-route.tsx +18 -3
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
:is(.asideDesktop-uFlpwO, .byline-admin-menu-drawer-aside-desktop) {
|
|
32
32
|
flex-shrink: 0;
|
|
33
|
-
align-self:
|
|
34
|
-
|
|
33
|
+
align-self: flex-start;
|
|
34
|
+
height: calc(100vh - 45px);
|
|
35
|
+
position: sticky;
|
|
36
|
+
top: 45px;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
:is(.asideDesktopOpen-kPyJOg, .byline-admin-menu-drawer-aside-desktop-open) {
|
|
@@ -43,10 +45,9 @@
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
:is(.nav-dDjqu7, .byline-admin-menu-drawer) {
|
|
46
|
-
|
|
48
|
+
flex: auto;
|
|
49
|
+
min-height: 0;
|
|
47
50
|
margin: 0;
|
|
48
|
-
position: sticky;
|
|
49
|
-
top: 0;
|
|
50
51
|
overflow-y: auto;
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { getAdminConfig, getWorkflowStatuses } from "@byline/core";
|
|
2
|
+
import { filterReadableCollections, getAdminConfig, getWorkflowStatuses, groupCollectionsForAdmin } from "@byline/core";
|
|
3
3
|
import { useTranslation } from "@byline/i18n/react";
|
|
4
4
|
import { Card, Container, Section } from "@byline/ui/react";
|
|
5
5
|
import clsx from "clsx";
|
|
6
|
+
import { useAbilities } from "../../integrations/abilities.js";
|
|
6
7
|
import { getAdminRoutePath } from "../../routes/admin-path.js";
|
|
7
8
|
import dashboard_module from "./dashboard.module.js";
|
|
8
9
|
import { Link } from "./loose-router.js";
|
|
@@ -50,82 +51,109 @@ function StatTile({ ws, count, collectionPath }) {
|
|
|
50
51
|
]
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
|
-
function
|
|
54
|
-
const config = getAdminConfig();
|
|
54
|
+
function CollectionCard({ collection, stats }) {
|
|
55
55
|
const { t } = useTranslation('byline-admin');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
const total = void 0 !== stats ? stats.reduce((sum, s)=>sum + s.count, 0) : void 0;
|
|
57
|
+
const workflowStatuses = getWorkflowStatuses(collection);
|
|
58
|
+
return /*#__PURE__*/ jsxs(Card, {
|
|
59
|
+
children: [
|
|
60
|
+
/*#__PURE__*/ jsx(Link, {
|
|
61
|
+
to: getAdminRoutePath('collections', '$collection'),
|
|
62
|
+
params: {
|
|
63
|
+
collection: collection.path
|
|
64
|
+
},
|
|
65
|
+
className: clsx('byline-dashboard-card-link', dashboard_module.cardLink),
|
|
66
|
+
children: /*#__PURE__*/ jsx(Card.Header, {
|
|
67
|
+
children: /*#__PURE__*/ jsxs("div", {
|
|
68
|
+
className: clsx('byline-dashboard-card-header', dashboard_module.cardHeader),
|
|
65
69
|
children: [
|
|
66
|
-
/*#__PURE__*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
className: clsx('byline-dashboard-
|
|
75
|
-
children:
|
|
76
|
-
|
|
77
|
-
className: clsx('byline-dashboard-card-title', dashboard_module.cardTitle),
|
|
78
|
-
children: [
|
|
79
|
-
/*#__PURE__*/ jsx("span", {
|
|
80
|
-
className: clsx('byline-dashboard-title-text', dashboard_module.titleText),
|
|
81
|
-
children: collection.labels.plural
|
|
82
|
-
}),
|
|
83
|
-
/*#__PURE__*/ jsx("span", {
|
|
84
|
-
className: clsx('muted byline-dashboard-title-meta', dashboard_module.titleMeta),
|
|
85
|
-
children: t('dashboard.totalCount', {
|
|
86
|
-
count: total
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
]
|
|
90
|
-
}),
|
|
91
|
-
/*#__PURE__*/ jsx(Card.Description, {
|
|
92
|
-
className: "muted",
|
|
93
|
-
children: t("dashboard.collectionDescription", {
|
|
94
|
-
label: collection.labels.plural
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
]
|
|
98
|
-
})
|
|
99
|
-
})
|
|
100
|
-
}),
|
|
101
|
-
/*#__PURE__*/ jsx(Card.Content, {
|
|
102
|
-
children: void 0 !== stats ? /*#__PURE__*/ jsx("div", {
|
|
103
|
-
className: clsx('byline-dashboard-stat-grid', dashboard_module.statGrid),
|
|
104
|
-
children: workflowStatuses.map((ws)=>{
|
|
105
|
-
const entry = stats.find((s)=>s.status === ws.name);
|
|
106
|
-
return /*#__PURE__*/ jsx(StatTile, {
|
|
107
|
-
ws: ws,
|
|
108
|
-
count: entry?.count ?? 0,
|
|
109
|
-
collectionPath: collection.path
|
|
110
|
-
}, ws.name);
|
|
111
|
-
})
|
|
112
|
-
}) : /*#__PURE__*/ jsx(Link, {
|
|
113
|
-
to: getAdminRoutePath('collections', '$collection'),
|
|
114
|
-
params: {
|
|
115
|
-
collection: collection.path
|
|
116
|
-
},
|
|
117
|
-
className: clsx('byline-dashboard-empty-link', dashboard_module.emptyLink),
|
|
118
|
-
children: /*#__PURE__*/ jsx("p", {
|
|
119
|
-
children: t("dashboard.collectionDescription", {
|
|
120
|
-
label: collection.labels.plural
|
|
70
|
+
/*#__PURE__*/ jsxs(Card.Title, {
|
|
71
|
+
className: clsx('byline-dashboard-card-title', dashboard_module.cardTitle),
|
|
72
|
+
children: [
|
|
73
|
+
/*#__PURE__*/ jsx("span", {
|
|
74
|
+
className: clsx('byline-dashboard-title-text', dashboard_module.titleText),
|
|
75
|
+
children: collection.labels.plural
|
|
76
|
+
}),
|
|
77
|
+
void 0 !== total && /*#__PURE__*/ jsx("span", {
|
|
78
|
+
className: clsx('muted byline-dashboard-title-meta', dashboard_module.titleMeta),
|
|
79
|
+
children: t('dashboard.totalCount', {
|
|
80
|
+
count: total
|
|
121
81
|
})
|
|
122
82
|
})
|
|
83
|
+
]
|
|
84
|
+
}),
|
|
85
|
+
/*#__PURE__*/ jsx(Card.Description, {
|
|
86
|
+
className: "muted",
|
|
87
|
+
children: t("dashboard.collectionDescription", {
|
|
88
|
+
label: collection.labels.plural
|
|
123
89
|
})
|
|
124
90
|
})
|
|
125
91
|
]
|
|
126
|
-
}
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}),
|
|
95
|
+
/*#__PURE__*/ jsx(Card.Content, {
|
|
96
|
+
children: void 0 !== stats ? /*#__PURE__*/ jsx("div", {
|
|
97
|
+
className: clsx('byline-dashboard-stat-grid', dashboard_module.statGrid),
|
|
98
|
+
children: workflowStatuses.map((ws)=>{
|
|
99
|
+
const entry = stats.find((s)=>s.status === ws.name);
|
|
100
|
+
return /*#__PURE__*/ jsx(StatTile, {
|
|
101
|
+
ws: ws,
|
|
102
|
+
count: entry?.count ?? 0,
|
|
103
|
+
collectionPath: collection.path
|
|
104
|
+
}, ws.name);
|
|
105
|
+
})
|
|
106
|
+
}) : /*#__PURE__*/ jsx(Link, {
|
|
107
|
+
to: getAdminRoutePath('collections', '$collection'),
|
|
108
|
+
params: {
|
|
109
|
+
collection: collection.path
|
|
110
|
+
},
|
|
111
|
+
className: clsx('byline-dashboard-empty-link', dashboard_module.emptyLink),
|
|
112
|
+
children: /*#__PURE__*/ jsx("p", {
|
|
113
|
+
children: t("dashboard.collectionDescription", {
|
|
114
|
+
label: collection.labels.plural
|
|
115
|
+
})
|
|
116
|
+
})
|
|
127
117
|
})
|
|
128
118
|
})
|
|
119
|
+
]
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function AdminDashboard({ statsMap }) {
|
|
123
|
+
const config = getAdminConfig();
|
|
124
|
+
const { t } = useTranslation('byline-admin');
|
|
125
|
+
const { isSuperAdmin, abilities } = useAbilities();
|
|
126
|
+
const visible = filterReadableCollections(config.collections, {
|
|
127
|
+
isSuperAdmin,
|
|
128
|
+
abilities
|
|
129
|
+
});
|
|
130
|
+
const buckets = groupCollectionsForAdmin(visible, config.admin, config.collectionGroups);
|
|
131
|
+
if (0 === buckets.length) return /*#__PURE__*/ jsx(Section, {
|
|
132
|
+
children: /*#__PURE__*/ jsx(Container, {
|
|
133
|
+
children: /*#__PURE__*/ jsx("p", {
|
|
134
|
+
className: "muted",
|
|
135
|
+
children: t('dashboard.noCollections')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
});
|
|
139
|
+
return /*#__PURE__*/ jsx(Section, {
|
|
140
|
+
children: /*#__PURE__*/ jsx(Container, {
|
|
141
|
+
children: buckets.map((bucket)=>/*#__PURE__*/ jsxs("section", {
|
|
142
|
+
className: clsx('byline-dashboard-group', dashboard_module.group),
|
|
143
|
+
children: [
|
|
144
|
+
null !== bucket.label && /*#__PURE__*/ jsx("h2", {
|
|
145
|
+
className: clsx('byline-dashboard-group-heading', dashboard_module.groupHeading),
|
|
146
|
+
children: bucket.label
|
|
147
|
+
}),
|
|
148
|
+
/*#__PURE__*/ jsx("div", {
|
|
149
|
+
className: clsx('byline-dashboard-grid', dashboard_module.grid),
|
|
150
|
+
children: bucket.collections.map((collection)=>/*#__PURE__*/ jsx(CollectionCard, {
|
|
151
|
+
collection: collection,
|
|
152
|
+
stats: statsMap[collection.path]
|
|
153
|
+
}, collection.path))
|
|
154
|
+
})
|
|
155
|
+
]
|
|
156
|
+
}, bucket.name ?? '__ungrouped__'))
|
|
129
157
|
})
|
|
130
158
|
});
|
|
131
159
|
}
|
|
@@ -14,6 +14,8 @@ const dashboard_module = {
|
|
|
14
14
|
tilePublished: "tilePublished-J3RcRP",
|
|
15
15
|
tileArchived: "tileArchived-xNthEz",
|
|
16
16
|
tileCustom: "tileCustom-cAV1h6",
|
|
17
|
-
emptyLink: "emptyLink-UVXqHV"
|
|
17
|
+
emptyLink: "emptyLink-UVXqHV",
|
|
18
|
+
group: "group-Q8QBu_",
|
|
19
|
+
groupHeading: "groupHeading-RsImn4"
|
|
18
20
|
};
|
|
19
21
|
export default dashboard_module;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
:is(.grid-aNPhoI, .byline-dashboard-grid) {
|
|
2
|
-
grid-template-columns: repeat(auto-
|
|
2
|
+
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
|
3
3
|
gap: 1.5rem;
|
|
4
4
|
display: grid;
|
|
5
5
|
}
|
|
@@ -173,3 +173,29 @@
|
|
|
173
173
|
display: block;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
:is(.group-Q8QBu_, .byline-dashboard-group) {
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
gap: 1rem;
|
|
179
|
+
display: flex;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
:is(.group-Q8QBu_ + .group-Q8QBu_, .byline-dashboard-group + .byline-dashboard-group) {
|
|
183
|
+
margin-top: 2.5rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
:is(.groupHeading-RsImn4, .byline-dashboard-group-heading) {
|
|
187
|
+
border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
|
|
188
|
+
letter-spacing: .02em;
|
|
189
|
+
text-transform: uppercase;
|
|
190
|
+
color: var(--gray-600);
|
|
191
|
+
margin: 0;
|
|
192
|
+
padding-bottom: .5rem;
|
|
193
|
+
font-size: 1.125rem;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
:is(:is([data-theme="dark"], .dark) .groupHeading-RsImn4, :is([data-theme="dark"], .dark) .byline-dashboard-group-heading) {
|
|
198
|
+
border-bottom-color: var(--gray-700);
|
|
199
|
+
color: var(--gray-400);
|
|
200
|
+
}
|
|
201
|
+
|
|
@@ -49,6 +49,7 @@ export declare function useAbilities(): {
|
|
|
49
49
|
has: (ability: string) => boolean;
|
|
50
50
|
hasAny: (abilities: readonly string[]) => boolean;
|
|
51
51
|
isSuperAdmin: boolean;
|
|
52
|
+
abilities: readonly string[];
|
|
52
53
|
};
|
|
53
54
|
/**
|
|
54
55
|
* JSX wrapper. Renders `children` only when the current admin holds
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
-
import { getAdminConfig } from "@byline/core";
|
|
3
|
+
import { filterReadableCollections, getAdminConfig } from "@byline/core";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
5
|
import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
|
|
6
6
|
import { AdminDashboard } from "../admin-shell/chrome/dashboard.js";
|
|
@@ -8,10 +8,14 @@ import { getCollectionStats } from "../server-fns/collections/index.js";
|
|
|
8
8
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
9
9
|
function createAdminDashboardRoute(path) {
|
|
10
10
|
const Route = createFileRoute(path)({
|
|
11
|
-
loader: async ()=>{
|
|
11
|
+
loader: async ({ context })=>{
|
|
12
12
|
const { collections } = getAdminConfig();
|
|
13
|
+
const visible = filterReadableCollections(collections, {
|
|
14
|
+
isSuperAdmin: context.user.is_super_admin,
|
|
15
|
+
abilities: context.user.abilities
|
|
16
|
+
});
|
|
13
17
|
const statsMap = {};
|
|
14
|
-
await Promise.all(
|
|
18
|
+
await Promise.all(visible.filter((c)=>true === c.showStats).map(async (c)=>{
|
|
15
19
|
try {
|
|
16
20
|
statsMap[c.path] = await getCollectionStats(c.path);
|
|
17
21
|
} catch {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.14.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -117,13 +117,13 @@
|
|
|
117
117
|
"react-json-view-lite": "^2.5.0",
|
|
118
118
|
"react-swipeable": "^7.0.2",
|
|
119
119
|
"zod": "^4.4.3",
|
|
120
|
-
"@byline/
|
|
121
|
-
"@byline/
|
|
122
|
-
"@byline/ai": "4.
|
|
123
|
-
"@byline/
|
|
124
|
-
"@byline/
|
|
125
|
-
"@byline/
|
|
126
|
-
"@byline/ui": "4.
|
|
120
|
+
"@byline/auth": "4.14.1",
|
|
121
|
+
"@byline/admin": "4.14.1",
|
|
122
|
+
"@byline/ai": "4.14.1",
|
|
123
|
+
"@byline/i18n": "4.14.1",
|
|
124
|
+
"@byline/client": "4.14.1",
|
|
125
|
+
"@byline/core": "4.14.1",
|
|
126
|
+
"@byline/ui": "4.14.1"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
129
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -136,8 +136,8 @@
|
|
|
136
136
|
"@biomejs/biome": "2.5.9",
|
|
137
137
|
"@rsbuild/plugin-react": "^2.1.0",
|
|
138
138
|
"@rslib/core": "^0.23.2",
|
|
139
|
-
"@tanstack/react-router": "^1.170.
|
|
140
|
-
"@tanstack/react-start": "^1.168.
|
|
139
|
+
"@tanstack/react-router": "^1.170.31",
|
|
140
|
+
"@tanstack/react-start": "^1.168.48",
|
|
141
141
|
"@types/node": "^26.2.0",
|
|
142
142
|
"@types/react": "19.2.18",
|
|
143
143
|
"@types/react-dom": "19.2.4",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"rimraf": "^6.1.3",
|
|
149
149
|
"typescript": "^7.0.2",
|
|
150
150
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
151
|
-
"vite": "^8.2.
|
|
151
|
+
"vite": "^8.2.2",
|
|
152
152
|
"vitest": "^4.1.11"
|
|
153
153
|
},
|
|
154
154
|
"publishConfig": {
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* Override handle: .byline-admin-layout-main
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/* `min-height`, not `height`: the document is the scroll container, so with a
|
|
10
|
+
fixed 100vh height the flex row ends at the fold and the sticky menu drawer
|
|
11
|
+
inside it has nothing left to stick to — it scrolls away as soon as the
|
|
12
|
+
route content runs past one viewport. Growing with the content gives the
|
|
13
|
+
drawer a containing block tall enough to stay pinned all the way down. */
|
|
9
14
|
.main,
|
|
10
15
|
:global(.byline-admin-layout-main) {
|
|
11
16
|
display: flex;
|
|
12
|
-
height: 100vh;
|
|
17
|
+
min-height: 100vh;
|
|
13
18
|
width: 100%;
|
|
14
19
|
max-width: 100%;
|
|
15
20
|
padding-top: 45px;
|
|
@@ -47,10 +47,16 @@
|
|
|
47
47
|
overflow: hidden;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/* Pinned under the fixed app bar (45px) for the whole scroll of the page.
|
|
51
|
+
`align-self: flex-start` keeps the sticky box from being stretched to the
|
|
52
|
+
full height of the flex row — a stretched sticky item has no room to move
|
|
53
|
+
and never pins. */
|
|
50
54
|
.asideDesktop,
|
|
51
55
|
:global(.byline-admin-menu-drawer-aside-desktop) {
|
|
52
|
-
position:
|
|
53
|
-
|
|
56
|
+
position: sticky;
|
|
57
|
+
top: 45px;
|
|
58
|
+
align-self: flex-start;
|
|
59
|
+
height: calc(100vh - 45px);
|
|
54
60
|
flex-shrink: 0;
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -64,12 +70,13 @@
|
|
|
64
70
|
width: 60px;
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/* The aside owns the viewport-height box; the nav just fills it and scrolls
|
|
74
|
+
internally when the menu is longer than the drawer. */
|
|
67
75
|
.nav,
|
|
68
76
|
:global(.byline-admin-menu-drawer) {
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
flex: 1 1 auto;
|
|
78
|
+
min-height: 0;
|
|
71
79
|
margin: 0;
|
|
72
|
-
max-height: calc(100vh - 60px);
|
|
73
80
|
overflow-y: auto;
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* AdminDashboard — collection-summary cards on /admin.
|
|
3
3
|
*
|
|
4
4
|
* Override handles:
|
|
5
|
-
* .byline-dashboard-grid —
|
|
5
|
+
* .byline-dashboard-grid — auto-fit card grid inside each group
|
|
6
|
+
* .byline-dashboard-group — one section per group (and the
|
|
7
|
+
* heading-less ungrouped band)
|
|
8
|
+
* .byline-dashboard-group-heading — the group's heading
|
|
6
9
|
* .byline-dashboard-card-header — header row of each card
|
|
7
10
|
* .byline-dashboard-card-title — flex row inside the header
|
|
8
11
|
* .byline-dashboard-title-text — collection plural-name label
|
|
@@ -21,7 +24,11 @@
|
|
|
21
24
|
.grid,
|
|
22
25
|
:global(.byline-dashboard-grid) {
|
|
23
26
|
display: grid;
|
|
24
|
-
|
|
27
|
+
/* `auto-fill` rather than `auto-fit`: each group owns its own grid, so a
|
|
28
|
+
group holding a single collection would stretch that card across the full
|
|
29
|
+
row under `auto-fit`. `auto-fill` keeps the empty tracks, so cards are the
|
|
30
|
+
same width in every group regardless of how many the group holds. */
|
|
31
|
+
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
|
25
32
|
gap: 1.5rem;
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -243,3 +250,36 @@
|
|
|
243
250
|
:global(.byline-dashboard-empty-link) {
|
|
244
251
|
display: block;
|
|
245
252
|
}
|
|
253
|
+
|
|
254
|
+
/* Group sections — one per dashboard collection group, plus the leading
|
|
255
|
+
ungrouped band, which renders with no heading. */
|
|
256
|
+
|
|
257
|
+
.group,
|
|
258
|
+
:global(.byline-dashboard-group) {
|
|
259
|
+
display: flex;
|
|
260
|
+
flex-direction: column;
|
|
261
|
+
gap: 1rem;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.group + .group,
|
|
265
|
+
:global(.byline-dashboard-group) + :global(.byline-dashboard-group) {
|
|
266
|
+
margin-top: 2.5rem;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.groupHeading,
|
|
270
|
+
:global(.byline-dashboard-group-heading) {
|
|
271
|
+
margin: 0;
|
|
272
|
+
padding-bottom: 0.5rem;
|
|
273
|
+
border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
|
|
274
|
+
font-size: 1.125rem;
|
|
275
|
+
font-weight: 600;
|
|
276
|
+
letter-spacing: 0.02em;
|
|
277
|
+
text-transform: uppercase;
|
|
278
|
+
color: var(--gray-600);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
:is([data-theme="dark"], :global(.dark)) .groupHeading,
|
|
282
|
+
:is([data-theme="dark"], :global(.dark)) :global(.byline-dashboard-group-heading) {
|
|
283
|
+
border-bottom-color: var(--gray-700);
|
|
284
|
+
color: var(--gray-400);
|
|
285
|
+
}
|
|
@@ -6,12 +6,18 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { WorkflowStatus } from '@byline/core'
|
|
10
|
-
import {
|
|
9
|
+
import type { CollectionDefinition, WorkflowStatus } from '@byline/core'
|
|
10
|
+
import {
|
|
11
|
+
filterReadableCollections,
|
|
12
|
+
getAdminConfig,
|
|
13
|
+
getWorkflowStatuses,
|
|
14
|
+
groupCollectionsForAdmin,
|
|
15
|
+
} from '@byline/core'
|
|
11
16
|
import { useTranslation } from '@byline/i18n/react'
|
|
12
17
|
import { Card, Container, Section } from '@byline/ui/react'
|
|
13
18
|
import cx from 'clsx'
|
|
14
19
|
|
|
20
|
+
import { useAbilities } from '../../integrations/abilities.jsx'
|
|
15
21
|
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
16
22
|
import styles from './dashboard.module.css'
|
|
17
23
|
import { Link } from './loose-router.js'
|
|
@@ -58,6 +64,76 @@ function StatTile({
|
|
|
58
64
|
)
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
function CollectionCard({
|
|
68
|
+
collection,
|
|
69
|
+
stats,
|
|
70
|
+
}: {
|
|
71
|
+
collection: CollectionDefinition
|
|
72
|
+
stats: CollectionStatusCount[] | undefined
|
|
73
|
+
}) {
|
|
74
|
+
const { t } = useTranslation('byline-admin')
|
|
75
|
+
// `stats` is undefined when the collection opted out of counts (no
|
|
76
|
+
// `showStats: true` on the definition) — the dashboard loader only fetches
|
|
77
|
+
// them for collections that opted in. Falling back to a total of zero there
|
|
78
|
+
// would read as "this collection is empty", so the count is omitted along
|
|
79
|
+
// with the status tiles.
|
|
80
|
+
const total = stats !== undefined ? stats.reduce((sum, s) => sum + s.count, 0) : undefined
|
|
81
|
+
const workflowStatuses = getWorkflowStatuses(collection)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Card>
|
|
85
|
+
<Link
|
|
86
|
+
to={getAdminRoutePath('collections', '$collection')}
|
|
87
|
+
params={{ collection: collection.path }}
|
|
88
|
+
className={cx('byline-dashboard-card-link', styles.cardLink)}
|
|
89
|
+
>
|
|
90
|
+
<Card.Header>
|
|
91
|
+
<div className={cx('byline-dashboard-card-header', styles.cardHeader)}>
|
|
92
|
+
<Card.Title className={cx('byline-dashboard-card-title', styles.cardTitle)}>
|
|
93
|
+
<span className={cx('byline-dashboard-title-text', styles.titleText)}>
|
|
94
|
+
{collection.labels.plural}
|
|
95
|
+
</span>
|
|
96
|
+
{total !== undefined && (
|
|
97
|
+
<span className={cx('muted byline-dashboard-title-meta', styles.titleMeta)}>
|
|
98
|
+
{t('dashboard.totalCount', { count: total })}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</Card.Title>
|
|
102
|
+
<Card.Description className="muted">
|
|
103
|
+
{t('dashboard.collectionDescription', { label: collection.labels.plural })}
|
|
104
|
+
</Card.Description>
|
|
105
|
+
</div>
|
|
106
|
+
</Card.Header>
|
|
107
|
+
</Link>
|
|
108
|
+
<Card.Content>
|
|
109
|
+
{stats !== undefined ? (
|
|
110
|
+
<div className={cx('byline-dashboard-stat-grid', styles.statGrid)}>
|
|
111
|
+
{workflowStatuses.map((ws) => {
|
|
112
|
+
const entry = stats.find((s) => s.status === ws.name)
|
|
113
|
+
return (
|
|
114
|
+
<StatTile
|
|
115
|
+
key={ws.name}
|
|
116
|
+
ws={ws}
|
|
117
|
+
count={entry?.count ?? 0}
|
|
118
|
+
collectionPath={collection.path}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
})}
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<Link
|
|
125
|
+
to={getAdminRoutePath('collections', '$collection')}
|
|
126
|
+
params={{ collection: collection.path }}
|
|
127
|
+
className={cx('byline-dashboard-empty-link', styles.emptyLink)}
|
|
128
|
+
>
|
|
129
|
+
<p>{t('dashboard.collectionDescription', { label: collection.labels.plural })}</p>
|
|
130
|
+
</Link>
|
|
131
|
+
)}
|
|
132
|
+
</Card.Content>
|
|
133
|
+
</Card>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
61
137
|
interface AdminDashboardProps {
|
|
62
138
|
statsMap: Record<string, CollectionStatusCount[]>
|
|
63
139
|
}
|
|
@@ -65,70 +141,48 @@ interface AdminDashboardProps {
|
|
|
65
141
|
export function AdminDashboard({ statsMap }: AdminDashboardProps) {
|
|
66
142
|
const config = getAdminConfig()
|
|
67
143
|
const { t } = useTranslation('byline-admin')
|
|
144
|
+
const { isSuperAdmin, abilities } = useAbilities()
|
|
145
|
+
|
|
146
|
+
// Filter before bucketing. A group left with no readable members arrives at
|
|
147
|
+
// `groupCollectionsForAdmin` empty and is skipped, so its heading disappears
|
|
148
|
+
// along with it — there is no group-level ability concept anywhere.
|
|
149
|
+
const visible = filterReadableCollections(config.collections, { isSuperAdmin, abilities })
|
|
150
|
+
const buckets = groupCollectionsForAdmin(visible, config.admin, config.collectionGroups)
|
|
151
|
+
|
|
152
|
+
if (buckets.length === 0) {
|
|
153
|
+
return (
|
|
154
|
+
<Section>
|
|
155
|
+
<Container>
|
|
156
|
+
<p className="muted">{t('dashboard.noCollections')}</p>
|
|
157
|
+
</Container>
|
|
158
|
+
</Section>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
68
161
|
|
|
69
162
|
return (
|
|
70
163
|
<Section>
|
|
71
164
|
<Container>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</span>
|
|
94
|
-
</Card.Title>
|
|
95
|
-
<Card.Description className="muted">
|
|
96
|
-
{t('dashboard.collectionDescription', { label: collection.labels.plural })}
|
|
97
|
-
</Card.Description>
|
|
98
|
-
</div>
|
|
99
|
-
</Card.Header>
|
|
100
|
-
</Link>
|
|
101
|
-
<Card.Content>
|
|
102
|
-
{stats !== undefined ? (
|
|
103
|
-
<div className={cx('byline-dashboard-stat-grid', styles.statGrid)}>
|
|
104
|
-
{workflowStatuses.map((ws) => {
|
|
105
|
-
const entry = stats.find((s) => s.status === ws.name)
|
|
106
|
-
return (
|
|
107
|
-
<StatTile
|
|
108
|
-
key={ws.name}
|
|
109
|
-
ws={ws}
|
|
110
|
-
count={entry?.count ?? 0}
|
|
111
|
-
collectionPath={collection.path}
|
|
112
|
-
/>
|
|
113
|
-
)
|
|
114
|
-
})}
|
|
115
|
-
</div>
|
|
116
|
-
) : (
|
|
117
|
-
<Link
|
|
118
|
-
to={getAdminRoutePath('collections', '$collection')}
|
|
119
|
-
params={{ collection: collection.path }}
|
|
120
|
-
className={cx('byline-dashboard-empty-link', styles.emptyLink)}
|
|
121
|
-
>
|
|
122
|
-
<p>
|
|
123
|
-
{t('dashboard.collectionDescription', { label: collection.labels.plural })}
|
|
124
|
-
</p>
|
|
125
|
-
</Link>
|
|
126
|
-
)}
|
|
127
|
-
</Card.Content>
|
|
128
|
-
</Card>
|
|
129
|
-
)
|
|
130
|
-
})}
|
|
131
|
-
</div>
|
|
165
|
+
{buckets.map((bucket) => (
|
|
166
|
+
<section
|
|
167
|
+
key={bucket.name ?? '__ungrouped__'}
|
|
168
|
+
className={cx('byline-dashboard-group', styles.group)}
|
|
169
|
+
>
|
|
170
|
+
{bucket.label !== null && (
|
|
171
|
+
<h2 className={cx('byline-dashboard-group-heading', styles.groupHeading)}>
|
|
172
|
+
{bucket.label}
|
|
173
|
+
</h2>
|
|
174
|
+
)}
|
|
175
|
+
<div className={cx('byline-dashboard-grid', styles.grid)}>
|
|
176
|
+
{bucket.collections.map((collection) => (
|
|
177
|
+
<CollectionCard
|
|
178
|
+
key={collection.path}
|
|
179
|
+
collection={collection}
|
|
180
|
+
stats={statsMap[collection.path]}
|
|
181
|
+
/>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</section>
|
|
185
|
+
))}
|
|
132
186
|
</Container>
|
|
133
187
|
</Section>
|
|
134
188
|
)
|
|
@@ -71,12 +71,13 @@ export function useAbilities(): {
|
|
|
71
71
|
has: (ability: string) => boolean
|
|
72
72
|
hasAny: (abilities: readonly string[]) => boolean
|
|
73
73
|
isSuperAdmin: boolean
|
|
74
|
+
abilities: readonly string[]
|
|
74
75
|
} {
|
|
75
76
|
const { user } = useAbilityRouteContext()
|
|
76
77
|
const has = (ability: string): boolean => user.is_super_admin || user.abilities.includes(ability)
|
|
77
78
|
const hasAny = (abilities: readonly string[]): boolean =>
|
|
78
79
|
user.is_super_admin || abilities.some((key) => user.abilities.includes(key))
|
|
79
|
-
return { has, hasAny, isSuperAdmin: user.is_super_admin }
|
|
80
|
+
return { has, hasAny, isSuperAdmin: user.is_super_admin, abilities: user.abilities }
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { createFileRoute } from '@tanstack/react-router'
|
|
10
10
|
|
|
11
|
-
import { getAdminConfig } from '@byline/core'
|
|
11
|
+
import { filterReadableCollections, getAdminConfig } from '@byline/core'
|
|
12
12
|
import { useTranslation } from '@byline/i18n/react'
|
|
13
13
|
|
|
14
14
|
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
@@ -19,12 +19,27 @@ import { getAdminRoutePath } from './admin-path.js'
|
|
|
19
19
|
export function createAdminDashboardRoute(path: string) {
|
|
20
20
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
|
|
21
21
|
const Route: any = createFileRoute(path as never)({
|
|
22
|
-
loader: async (
|
|
22
|
+
loader: async ({
|
|
23
|
+
context,
|
|
24
|
+
}: {
|
|
25
|
+
context: { user: { is_super_admin: boolean; abilities: string[] } }
|
|
26
|
+
}) => {
|
|
23
27
|
const { collections } = getAdminConfig()
|
|
28
|
+
|
|
29
|
+
// Only fetch counts for collections this administrator can read. Without
|
|
30
|
+
// the filter, `getCollectionStats` fires for every collection and the
|
|
31
|
+
// ones it cannot read throw inside `countByStatus`, get swallowed, and
|
|
32
|
+
// land as an empty array — rendering every status tile as zero, which is
|
|
33
|
+
// indistinguishable from a genuinely empty collection.
|
|
34
|
+
const visible = filterReadableCollections(collections, {
|
|
35
|
+
isSuperAdmin: context.user.is_super_admin,
|
|
36
|
+
abilities: context.user.abilities,
|
|
37
|
+
})
|
|
38
|
+
|
|
24
39
|
const statsMap: Record<string, CollectionStatusCount[]> = {}
|
|
25
40
|
|
|
26
41
|
await Promise.all(
|
|
27
|
-
|
|
42
|
+
visible
|
|
28
43
|
.filter((c) => c.showStats === true)
|
|
29
44
|
.map(async (c) => {
|
|
30
45
|
try {
|