@byline/host-tanstack-start 4.13.0 → 4.14.0

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,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 = stats?.reduce((sum, s)=>sum + s.count, 0) ?? 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
+ /*#__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.0",
6
+ "version": "4.14.0",
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/ai": "4.13.0",
121
- "@byline/core": "4.13.0",
122
- "@byline/admin": "4.13.0",
123
- "@byline/client": "4.13.0",
124
- "@byline/i18n": "4.13.0",
125
- "@byline/ui": "4.13.0",
126
- "@byline/auth": "4.13.0"
120
+ "@byline/admin": "4.14.0",
121
+ "@byline/auth": "4.14.0",
122
+ "@byline/ai": "4.14.0",
123
+ "@byline/client": "4.14.0",
124
+ "@byline/core": "4.14.0",
125
+ "@byline/i18n": "4.14.0",
126
+ "@byline/ui": "4.14.0"
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": {
@@ -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,69 @@ 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
+ const total = stats?.reduce((sum, s) => sum + s.count, 0) ?? 0
76
+ const workflowStatuses = getWorkflowStatuses(collection)
77
+
78
+ return (
79
+ <Card>
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>{t('dashboard.collectionDescription', { label: collection.labels.plural })}</p>
123
+ </Link>
124
+ )}
125
+ </Card.Content>
126
+ </Card>
127
+ )
128
+ }
129
+
61
130
  interface AdminDashboardProps {
62
131
  statsMap: Record<string, CollectionStatusCount[]>
63
132
  }
@@ -65,70 +134,48 @@ interface AdminDashboardProps {
65
134
  export function AdminDashboard({ statsMap }: AdminDashboardProps) {
66
135
  const config = getAdminConfig()
67
136
  const { t } = useTranslation('byline-admin')
137
+ const { isSuperAdmin, abilities } = useAbilities()
138
+
139
+ // Filter before bucketing. A group left with no readable members arrives at
140
+ // `groupCollectionsForAdmin` empty and is skipped, so its heading disappears
141
+ // along with it — there is no group-level ability concept anywhere.
142
+ const visible = filterReadableCollections(config.collections, { isSuperAdmin, abilities })
143
+ const buckets = groupCollectionsForAdmin(visible, config.admin, config.collectionGroups)
144
+
145
+ if (buckets.length === 0) {
146
+ return (
147
+ <Section>
148
+ <Container>
149
+ <p className="muted">{t('dashboard.noCollections')}</p>
150
+ </Container>
151
+ </Section>
152
+ )
153
+ }
68
154
 
69
155
  return (
70
156
  <Section>
71
157
  <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>
158
+ {buckets.map((bucket) => (
159
+ <section
160
+ key={bucket.name ?? '__ungrouped__'}
161
+ className={cx('byline-dashboard-group', styles.group)}
162
+ >
163
+ {bucket.label !== null && (
164
+ <h2 className={cx('byline-dashboard-group-heading', styles.groupHeading)}>
165
+ {bucket.label}
166
+ </h2>
167
+ )}
168
+ <div className={cx('byline-dashboard-grid', styles.grid)}>
169
+ {bucket.collections.map((collection) => (
170
+ <CollectionCard
171
+ key={collection.path}
172
+ collection={collection}
173
+ stats={statsMap[collection.path]}
174
+ />
175
+ ))}
176
+ </div>
177
+ </section>
178
+ ))}
132
179
  </Container>
133
180
  </Section>
134
181
  )
@@ -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 {