@byline/host-tanstack-start 3.10.1 → 3.11.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.
@@ -45,7 +45,7 @@ export declare const listAdminUsers: import("@tanstack/react-start").RequiredFet
45
45
  page: number;
46
46
  page_size: number;
47
47
  query: string;
48
- order: "created_at" | "updated_at" | "email" | "given_name" | "family_name" | "username";
48
+ order: "email" | "created_at" | "updated_at" | "given_name" | "family_name" | "username";
49
49
  desc: boolean;
50
50
  };
51
51
  }>>;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.10.1",
6
+ "version": "3.11.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/server-fns/admin-account/index.d.ts",
46
46
  "import": "./dist/server-fns/admin-account/index.js"
47
47
  },
48
+ "./server-fns/admin-activity": {
49
+ "types": "./dist/server-fns/admin-activity/index.d.ts",
50
+ "import": "./dist/server-fns/admin-activity/index.js"
51
+ },
48
52
  "./server-fns/ai": {
49
53
  "types": "./dist/server-fns/ai/index.d.ts",
50
54
  "import": "./dist/server-fns/ai/index.js"
@@ -115,13 +119,13 @@
115
119
  "react-swipeable": "^7.0.2",
116
120
  "uuid": "^14.0.0",
117
121
  "zod": "^4.4.3",
118
- "@byline/admin": "3.10.1",
119
- "@byline/ui": "3.10.1",
120
- "@byline/i18n": "3.10.1",
121
- "@byline/core": "3.10.1",
122
- "@byline/client": "3.10.1",
123
- "@byline/ai": "3.10.1",
124
- "@byline/auth": "3.10.1"
122
+ "@byline/ai": "3.11.0",
123
+ "@byline/admin": "3.11.0",
124
+ "@byline/auth": "3.11.0",
125
+ "@byline/i18n": "3.11.0",
126
+ "@byline/ui": "3.11.0",
127
+ "@byline/core": "3.11.0",
128
+ "@byline/client": "3.11.0"
125
129
  },
126
130
  "peerDependencies": {
127
131
  "@tanstack/react-router": "^1.167.0",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ActivitySystemView — the system-wide activity report (docs/AUDIT.md — W4).
3
+ *
4
+ * Override handles:
5
+ * .byline-activity-head — title row
6
+ * .byline-activity-title — page title
7
+ * .byline-activity-options — filter + pager bar
8
+ * .byline-activity-empty — empty-state line
9
+ * .byline-activity-table-wrap — table container
10
+ * .byline-activity-when — timestamp cell
11
+ * .byline-activity-change — before → after cell
12
+ * .byline-activity-before — pre-change value
13
+ * .byline-activity-arrow — muted transition arrow
14
+ * .byline-activity-after — post-change value
15
+ */
16
+
17
+ .head,
18
+ :global(.byline-activity-head) {
19
+ margin-bottom: 0.5rem;
20
+ }
21
+
22
+ .title,
23
+ :global(.byline-activity-title) {
24
+ font-size: 1.25rem;
25
+ font-weight: 600;
26
+ }
27
+
28
+ .options,
29
+ :global(.byline-activity-options) {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 0.5rem;
33
+ flex-wrap: wrap;
34
+ margin: 0.75rem 0;
35
+ }
36
+
37
+ .empty,
38
+ :global(.byline-activity-empty) {
39
+ margin: 1.5rem 0;
40
+ color: var(--gray-500);
41
+ font-size: 0.875rem;
42
+ }
43
+
44
+ :is([data-theme="dark"], :global(.dark)) .empty,
45
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-activity-empty) {
46
+ color: var(--gray-400);
47
+ }
48
+
49
+ .tableWrap,
50
+ :global(.byline-activity-table-wrap) {
51
+ margin-top: 0.5rem;
52
+ margin-bottom: 0.75rem;
53
+ }
54
+
55
+ .when,
56
+ :global(.byline-activity-when) {
57
+ white-space: nowrap;
58
+ font-variant-numeric: tabular-nums;
59
+ }
60
+
61
+ .change,
62
+ :global(.byline-activity-change) {
63
+ font-variant-numeric: tabular-nums;
64
+ }
65
+
66
+ .arrow,
67
+ :global(.byline-activity-arrow) {
68
+ color: var(--gray-400);
69
+ }
@@ -0,0 +1,242 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useRouterState } from '@tanstack/react-router'
12
+
13
+ import { getClientConfig } from '@byline/core'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { Button, Container, Section, Select, Table } from '@byline/ui/react'
16
+ import cx from 'classnames'
17
+
18
+ import { Link, useNavigate } from '../chrome/loose-router.js'
19
+ import { RouterPager } from '../chrome/router-pager.js'
20
+ import styles from './list.module.css'
21
+ import type { SystemActivityResponse } from '../../server-fns/admin-activity/index.js'
22
+
23
+ /**
24
+ * Namespaced audit `action` → i18n label key. Covers the version-stream's
25
+ * synthesised content-save actions (`document.created` / `document.updated`)
26
+ * as well as the audit-log actions surfaced on the per-document history tab.
27
+ * Unknown actions fall back to the raw value rather than a missing-key warning.
28
+ */
29
+ const ACTION_KEYS: Record<string, string> = {
30
+ 'document.created': 'activity.actions.created',
31
+ 'document.updated': 'activity.actions.updated',
32
+ 'document.path.changed': 'activity.actions.pathChanged',
33
+ 'document.locales.changed': 'activity.actions.localesChanged',
34
+ 'document.status.changed': 'activity.actions.statusChanged',
35
+ 'document.deleted': 'activity.actions.deleted',
36
+ }
37
+
38
+ /** The selectable action types, in display order. `_all` clears the filter. */
39
+ const ACTION_FILTER_VALUES = [
40
+ 'document.created',
41
+ 'document.updated',
42
+ 'document.status.changed',
43
+ 'document.path.changed',
44
+ 'document.locales.changed',
45
+ 'document.deleted',
46
+ ] as const
47
+
48
+ /** Render an audit before/after value inline: arrays comma-join, nullish → em-dash. */
49
+ function formatAuditValue(value: unknown): string {
50
+ if (value == null) return '—'
51
+ if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—'
52
+ return String(value)
53
+ }
54
+
55
+ /**
56
+ * The system-wide activity report (docs/AUDIT.md — Workstream 4): a paged,
57
+ * filterable feed over the union of the version stream (content saves) and the
58
+ * audit log (status / path / locale changes, deletions, and future admin-realm
59
+ * events). Read-only; gated by `admin.activity.read`.
60
+ */
61
+ export const ActivitySystemView = ({ data }: { data: SystemActivityResponse }) => {
62
+ const { t } = useTranslation('byline-admin')
63
+ const navigate = useNavigate()
64
+ const location = useRouterState({ select: (s) => s.location })
65
+ const search = location.search as {
66
+ collection?: string
67
+ action?: string
68
+ page?: number
69
+ }
70
+
71
+ const entries = data?.entries ?? []
72
+ const collections = getClientConfig().collections
73
+
74
+ // Navigate with the changed filter merged into the URL search; clearing the
75
+ // page so a filter change always lands on page 1. `_all` removes the filter.
76
+ const applyFilter = (key: 'collection' | 'action', value: string) => {
77
+ const params = structuredClone(location.search) as Record<string, unknown>
78
+ params.page = undefined
79
+ if (value === '_all') {
80
+ params[key] = undefined
81
+ } else {
82
+ params[key] = value
83
+ }
84
+ navigate({ to: '/admin/activity' as never, search: params })
85
+ }
86
+
87
+ const collectionItems = [
88
+ { value: '_all', label: t('activity.filters.allCollections') },
89
+ ...collections.map((c) => ({
90
+ value: c.path,
91
+ label: (c.labels?.plural as string) ?? c.path,
92
+ })),
93
+ ]
94
+
95
+ const actionItems = [
96
+ { value: '_all', label: t('activity.filters.allActions') },
97
+ ...ACTION_FILTER_VALUES.map((a) => ({
98
+ value: a,
99
+ label: ACTION_KEYS[a] ? t(ACTION_KEYS[a]) : a,
100
+ })),
101
+ ]
102
+
103
+ const hasFilters = search.collection != null || search.action != null
104
+
105
+ return (
106
+ <Section>
107
+ <Container>
108
+ <div className={cx('byline-activity-head', styles.head)}>
109
+ <h1 className={cx('byline-activity-title', styles.title)}>{t('activity.title')}</h1>
110
+ </div>
111
+
112
+ <div className={cx('byline-activity-options', styles.options)}>
113
+ <Select<string>
114
+ id="activity_collection_filter"
115
+ name="activity_collection_filter"
116
+ size="sm"
117
+ value={search.collection ?? '_all'}
118
+ items={collectionItems}
119
+ onValueChange={(v) => {
120
+ if (typeof v === 'string') applyFilter('collection', v)
121
+ }}
122
+ />
123
+ <Select<string>
124
+ id="activity_action_filter"
125
+ name="activity_action_filter"
126
+ size="sm"
127
+ value={search.action ?? '_all'}
128
+ items={actionItems}
129
+ onValueChange={(v) => {
130
+ if (typeof v === 'string') applyFilter('action', v)
131
+ }}
132
+ />
133
+ {hasFilters && (
134
+ <Button
135
+ size="sm"
136
+ variant="text"
137
+ onClick={() => navigate({ to: '/admin/activity' as never, search: {} })}
138
+ >
139
+ {t('activity.filters.clear')}
140
+ </Button>
141
+ )}
142
+ <RouterPager
143
+ page={data.meta.page}
144
+ count={data.meta.totalPages}
145
+ showFirstButton
146
+ showLastButton
147
+ componentName="pagerTop"
148
+ aria-label={t('activity.pagerAriaLabel')}
149
+ />
150
+ </div>
151
+
152
+ {entries.length === 0 ? (
153
+ <p className={cx('byline-activity-empty', styles.empty)}>{t('activity.empty')}</p>
154
+ ) : (
155
+ <Table.Container className={cx('byline-activity-table-wrap', styles.tableWrap)}>
156
+ <Table>
157
+ <Table.Header>
158
+ <Table.Row>
159
+ <Table.HeadingCell scope="col">{t('activity.columns.when')}</Table.HeadingCell>
160
+ <Table.HeadingCell scope="col">
161
+ {t('activity.columns.collection')}
162
+ </Table.HeadingCell>
163
+ <Table.HeadingCell scope="col">{t('activity.columns.action')}</Table.HeadingCell>
164
+ <Table.HeadingCell scope="col">{t('activity.columns.actor')}</Table.HeadingCell>
165
+ <Table.HeadingCell scope="col">{t('activity.columns.change')}</Table.HeadingCell>
166
+ </Table.Row>
167
+ </Table.Header>
168
+ <Table.Body>
169
+ {entries.map((entry) => {
170
+ const actionKey = ACTION_KEYS[entry.action]
171
+ const actionLabel = actionKey ? t(actionKey) : entry.action
172
+ // System/tooling write (NULL actor or 'system' realm) → the
173
+ // system label; an unresolved id is a deleted user; otherwise
174
+ // the admin-resolved label.
175
+ const actorLabel =
176
+ entry.actorId == null || entry.actorRealm === 'system'
177
+ ? t('activity.systemActor')
178
+ : (data.actors?.[entry.actorId]?.label ?? t('activity.formerUser'))
179
+
180
+ const col = entry.collectionId ? data.collections[entry.collectionId] : undefined
181
+ const collectionLabel = col?.plural ?? col?.path ?? '—'
182
+
183
+ const hasChange = entry.before != null || entry.after != null
184
+
185
+ return (
186
+ <Table.Row key={entry.id}>
187
+ <Table.Cell className={cx('byline-activity-when', styles.when)}>
188
+ {new Date(entry.occurredAt).toLocaleString()}
189
+ </Table.Cell>
190
+ <Table.Cell>
191
+ {col != null && entry.documentId != null ? (
192
+ <Link
193
+ to={'/admin/collections/$collection/$id' as never}
194
+ params={{ collection: col.path, id: entry.documentId }}
195
+ >
196
+ {collectionLabel}
197
+ </Link>
198
+ ) : (
199
+ collectionLabel
200
+ )}
201
+ </Table.Cell>
202
+ <Table.Cell>{actionLabel}</Table.Cell>
203
+ <Table.Cell>{actorLabel}</Table.Cell>
204
+ <Table.Cell className={cx('byline-activity-change', styles.change)}>
205
+ {hasChange ? (
206
+ <>
207
+ <span className={cx('byline-activity-before', styles.before)}>
208
+ {formatAuditValue(entry.before)}
209
+ </span>
210
+ <span className={cx('byline-activity-arrow', styles.arrow)}>
211
+ {' → '}
212
+ </span>
213
+ <span className={cx('byline-activity-after', styles.after)}>
214
+ {formatAuditValue(entry.after)}
215
+ </span>
216
+ </>
217
+ ) : (
218
+ '—'
219
+ )}
220
+ </Table.Cell>
221
+ </Table.Row>
222
+ )
223
+ })}
224
+ </Table.Body>
225
+ </Table>
226
+ </Table.Container>
227
+ )}
228
+
229
+ <div className={cx('byline-activity-options', styles.options)}>
230
+ <RouterPager
231
+ page={data.meta.page}
232
+ count={data.meta.totalPages}
233
+ showFirstButton
234
+ showLastButton
235
+ componentName="pagerBottom"
236
+ aria-label={t('activity.pagerAriaLabel')}
237
+ />
238
+ </div>
239
+ </Container>
240
+ </Section>
241
+ )
242
+ }
@@ -9,11 +9,20 @@
9
9
  import type React from 'react'
10
10
  import { useRouterState } from '@tanstack/react-router'
11
11
 
12
+ import { ADMIN_ACTIVITY_ABILITIES } from '@byline/admin/admin-activity'
12
13
  import { ADMIN_PERMISSIONS_ABILITIES } from '@byline/admin/admin-permissions'
13
14
  import { ADMIN_ROLES_ABILITIES } from '@byline/admin/admin-roles'
14
15
  import { ADMIN_USERS_ABILITIES } from '@byline/admin/admin-users'
15
16
  import { useTranslation } from '@byline/i18n/react'
16
- import { HomeIcon, RolesIcon, SettingsSlidersIcon, UserIcon, UsersIcon } from '@byline/ui/react'
17
+ import {
18
+ ActivityIcon,
19
+ HomeIcon,
20
+ RolesIcon,
21
+ SettingsSlidersIcon,
22
+ Tooltip,
23
+ UserIcon,
24
+ UsersIcon,
25
+ } from '@byline/ui/react'
17
26
  import cx from 'classnames'
18
27
  import { useSwipeable } from 'react-swipeable'
19
28
 
@@ -39,10 +48,22 @@ interface MenuItemProps {
39
48
  }
40
49
 
41
50
  function MenuItem({ to, label, icon, pathname, compact }: MenuItemProps) {
51
+ // In the compact (icon-only) state the label text is hidden, so a tooltip on
52
+ // the icon is the only way to discover what the item is. When labels are
53
+ // visible the tooltip would be redundant, so it's gated on `compact`. The
54
+ // span is the trigger (not the bare icon) so the ref / hover handlers Base UI
55
+ // merges always land on a DOM node regardless of how each icon forwards props.
56
+ const iconSpan = <span className="icon">{icon}</span>
42
57
  return (
43
58
  <li className={cx('menu-item', { active: isActive(pathname, to), compact })}>
44
59
  <Link to={to}>
45
- <span className="icon">{icon}</span>
60
+ {compact ? (
61
+ <Tooltip text={label} side="right">
62
+ {iconSpan}
63
+ </Tooltip>
64
+ ) : (
65
+ iconSpan
66
+ )}
46
67
  <span className="label">{label}</span>
47
68
  </Link>
48
69
  </li>
@@ -63,7 +84,8 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
63
84
  const canReadUsers = has(ADMIN_USERS_ABILITIES.read)
64
85
  const canReadRoles = has(ADMIN_ROLES_ABILITIES.read)
65
86
  const canReadPermissions = has(ADMIN_PERMISSIONS_ABILITIES.read)
66
- const showAdminSection = canReadUsers || canReadRoles || canReadPermissions
87
+ const canReadActivity = has(ADMIN_ACTIVITY_ABILITIES.read)
88
+ const showAdminSection = canReadUsers || canReadRoles || canReadPermissions || canReadActivity
67
89
 
68
90
  const handlers = useSwipeable({
69
91
  onSwipedLeft: () => {
@@ -133,6 +155,15 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
133
155
  compact={compact}
134
156
  />
135
157
  )}
158
+ {canReadActivity && (
159
+ <MenuItem
160
+ to="/admin/activity"
161
+ label={t('chrome.menu.activity')}
162
+ icon={<ActivityIcon width="20px" height="20px" />}
163
+ pathname={pathname}
164
+ compact={compact}
165
+ />
166
+ )}
136
167
  </>
137
168
  )}
138
169
  <li className="menu-separator" />
@@ -24,7 +24,7 @@
24
24
  import { useEffect, useState } from 'react'
25
25
 
26
26
  import { useTranslation } from '@byline/i18n/react'
27
- import { EyeClosedIcon, EyeOpenIcon } from '@byline/ui/react'
27
+ import { EyeClosedIcon, EyeOpenIcon, Tooltip } from '@byline/ui/react'
28
28
  import cx from 'classnames'
29
29
 
30
30
  import {
@@ -89,6 +89,7 @@ export function PreviewToggle({ compact }: PreviewToggleProps) {
89
89
  ) : (
90
90
  <EyeClosedIcon width="20px" height="20px" />
91
91
  )
92
+ const iconSpan = <span className="icon">{icon}</span>
92
93
 
93
94
  return (
94
95
  <li className={cx('menu-item byline-preview-toggle', { compact, active: preview })}>
@@ -99,9 +100,19 @@ export function PreviewToggle({ compact }: PreviewToggleProps) {
99
100
  aria-label={
100
101
  preview ? t('chrome.preview.disableAriaLabel') : t('chrome.preview.enableAriaLabel')
101
102
  }
102
- title={preview ? t('chrome.preview.onTitle') : t('chrome.preview.offTitle')}
103
+ // Native title for the expanded state; in compact mode the styled
104
+ // Tooltip below takes over so the two don't fire at once.
105
+ title={
106
+ compact ? undefined : preview ? t('chrome.preview.onTitle') : t('chrome.preview.offTitle')
107
+ }
103
108
  >
104
- <span className="icon">{icon}</span>
109
+ {compact ? (
110
+ <Tooltip text={label} side="right">
111
+ {iconSpan}
112
+ </Tooltip>
113
+ ) : (
114
+ iconSpan
115
+ )}
105
116
  <span className="label">{label}</span>
106
117
  </button>
107
118
  </li>
@@ -0,0 +1,76 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createFileRoute } from '@tanstack/react-router'
10
+
11
+ import { useTranslation } from '@byline/i18n/react'
12
+ import { z } from 'zod'
13
+
14
+ import { ActivitySystemView } from '../admin-shell/admin-activity/list.js'
15
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
16
+ import { getSystemActivityLog } from '../server-fns/admin-activity/index.js'
17
+ import type { SystemActivityResponse } from '../server-fns/admin-activity/index.js'
18
+
19
+ const searchSchema = z.object({
20
+ page: z.coerce.number().int().min(1).optional(),
21
+ page_size: z.coerce.number().int().min(1).max(100).optional(),
22
+ collection: z.string().optional(),
23
+ action: z.string().optional(),
24
+ actorId: z.string().optional(),
25
+ from: z.string().optional(),
26
+ to: z.string().optional(),
27
+ })
28
+
29
+ type ActivitySearch = z.infer<typeof searchSchema>
30
+
31
+ export function createAdminActivityRoute(path: string) {
32
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
33
+ const Route: any = createFileRoute(path as never)({
34
+ validateSearch: searchSchema,
35
+ loaderDeps: ({ search }: { search: ActivitySearch }) => ({
36
+ page: search.page,
37
+ page_size: search.page_size,
38
+ collection: search.collection,
39
+ action: search.action,
40
+ actorId: search.actorId,
41
+ from: search.from,
42
+ to: search.to,
43
+ }),
44
+ loader: async ({ deps }: { deps: ActivitySearch }) => {
45
+ const data = await getSystemActivityLog({
46
+ data: {
47
+ page: deps.page,
48
+ page_size: deps.page_size,
49
+ collection: deps.collection,
50
+ action: deps.action,
51
+ actorId: deps.actorId,
52
+ from: deps.from,
53
+ to: deps.to,
54
+ },
55
+ })
56
+ return { data }
57
+ },
58
+ component: function AdminActivityComponent() {
59
+ const { data } = Route.useLoaderData() as { data: SystemActivityResponse }
60
+ const { t } = useTranslation('byline-admin')
61
+ return (
62
+ <>
63
+ <BreadcrumbsClient
64
+ breadcrumbs={[
65
+ { label: t('chrome.menu.dashboard'), href: '/admin' },
66
+ { label: t('activity.title'), href: '/admin/activity' },
67
+ ]}
68
+ />
69
+ <ActivitySystemView data={data} />
70
+ </>
71
+ )
72
+ },
73
+ })
74
+
75
+ return Route
76
+ }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  export { createAdminAccountRoute } from './create-admin-account-route.js'
24
+ export { createAdminActivityRoute } from './create-admin-activity-route.js'
24
25
  export { createAdminDashboardRoute } from './create-admin-dashboard-route.js'
25
26
  export { createAdminLayoutRoute } from './create-admin-layout-route.js'
26
27
  export { createAdminPermissionsRoute } from './create-admin-permissions-route.js'