@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.
@@ -1,7 +1,7 @@
1
1
  :is(.main-EeTOie, .byline-admin-layout-main) {
2
2
  width: 100%;
3
3
  max-width: 100%;
4
- height: 100vh;
4
+ min-height: 100vh;
5
5
  padding-top: 45px;
6
6
  display: flex;
7
7
  }
@@ -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: stretch;
34
- position: relative;
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
- max-height: calc(100vh - 60px);
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 AdminDashboard({ statsMap }) {
54
- const config = getAdminConfig();
54
+ function CollectionCard({ collection, stats }) {
55
55
  const { t } = useTranslation('byline-admin');
56
- return /*#__PURE__*/ jsx(Section, {
57
- children: /*#__PURE__*/ jsx(Container, {
58
- children: /*#__PURE__*/ jsx("div", {
59
- className: clsx('byline-dashboard-grid', dashboard_module.grid),
60
- children: config.collections.map((collection)=>{
61
- const stats = statsMap[collection.path];
62
- const total = stats?.reduce((sum, s)=>sum + s.count, 0) ?? 0;
63
- const workflowStatuses = getWorkflowStatuses(collection);
64
- return /*#__PURE__*/ jsxs(Card, {
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__*/ jsx(Link, {
67
- to: getAdminRoutePath('collections', '$collection'),
68
- params: {
69
- collection: collection.path
70
- },
71
- className: clsx('byline-dashboard-card-link', dashboard_module.cardLink),
72
- children: /*#__PURE__*/ jsx(Card.Header, {
73
- children: /*#__PURE__*/ jsxs("div", {
74
- className: clsx('byline-dashboard-card-header', dashboard_module.cardHeader),
75
- children: [
76
- /*#__PURE__*/ jsxs(Card.Title, {
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
- }, collection.path);
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-fit, minmax(20rem, 1fr));
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
@@ -17,7 +17,8 @@ function useAbilities() {
17
17
  return {
18
18
  has,
19
19
  hasAny,
20
- isSuperAdmin: user.is_super_admin
20
+ isSuperAdmin: user.is_super_admin,
21
+ abilities: user.abilities
21
22
  };
22
23
  }
23
24
  function RequireAbility({ ability, children }) {
@@ -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(collections.filter((c)=>true === c.showStats).map(async (c)=>{
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.13.1",
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/admin": "4.13.1",
121
- "@byline/auth": "4.13.1",
122
- "@byline/ai": "4.13.1",
123
- "@byline/core": "4.13.1",
124
- "@byline/i18n": "4.13.1",
125
- "@byline/client": "4.13.1",
126
- "@byline/ui": "4.13.1"
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.30",
140
- "@tanstack/react-start": "^1.168.47",
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.1",
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: relative;
53
- align-self: stretch;
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
- position: sticky;
70
- top: 0;
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 — outer auto-fit card 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
- grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
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 { getAdminConfig, getWorkflowStatuses } from '@byline/core'
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
- <div className={cx('byline-dashboard-grid', styles.grid)}>
73
- {config.collections.map((collection) => {
74
- const stats = statsMap[collection.path]
75
- const total = stats?.reduce((sum, s) => sum + s.count, 0) ?? 0
76
- const workflowStatuses = getWorkflowStatuses(collection)
77
-
78
- return (
79
- <Card key={collection.path}>
80
- <Link
81
- to={getAdminRoutePath('collections', '$collection')}
82
- params={{ collection: collection.path }}
83
- className={cx('byline-dashboard-card-link', styles.cardLink)}
84
- >
85
- <Card.Header>
86
- <div className={cx('byline-dashboard-card-header', styles.cardHeader)}>
87
- <Card.Title className={cx('byline-dashboard-card-title', styles.cardTitle)}>
88
- <span className={cx('byline-dashboard-title-text', styles.titleText)}>
89
- {collection.labels.plural}
90
- </span>
91
- <span className={cx('muted byline-dashboard-title-meta', styles.titleMeta)}>
92
- {t('dashboard.totalCount', { count: total })}
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
- collections
42
+ visible
28
43
  .filter((c) => c.showStats === true)
29
44
  .map(async (c) => {
30
45
  try {