@byline/admin 5.1.2 → 5.1.4
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/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
|
@@ -266,3 +266,60 @@
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
:is(.listHeader-nWIpzD, .byline-analytics-list-header) {
|
|
270
|
+
justify-content: space-between;
|
|
271
|
+
align-items: flex-start;
|
|
272
|
+
gap: .75rem;
|
|
273
|
+
display: flex;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.listHeading-ljSubR {
|
|
277
|
+
min-width: 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
:is(.viewAll-jPtoC0, .byline-analytics-view-all) {
|
|
281
|
+
white-space: nowrap;
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
:is(.modal-_Fpti3, .byline-analytics-modal) {
|
|
286
|
+
width: min(40rem, 100vw - 2rem);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
:is(.modalCaption-Z7Qsck, .byline-analytics-modal-caption) {
|
|
290
|
+
font-variant-numeric: tabular-nums;
|
|
291
|
+
margin: .25rem 0 0;
|
|
292
|
+
font-size: .75rem;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
:is(.modalContent-raK5q1, .byline-analytics-modal-content) {
|
|
296
|
+
max-height: min(60vh, 48rem);
|
|
297
|
+
overflow-y: auto;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.modalStatus-bXbZr9 {
|
|
301
|
+
justify-content: center;
|
|
302
|
+
padding: 2rem 0;
|
|
303
|
+
display: flex;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
:is(.modalActions-XVBrOS, .byline-analytics-modal-actions) {
|
|
307
|
+
flex-wrap: wrap;
|
|
308
|
+
justify-content: space-between;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: .75rem;
|
|
311
|
+
display: flex;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.srOnly-szSAhX {
|
|
315
|
+
clip: rect(0, 0, 0, 0);
|
|
316
|
+
white-space: nowrap;
|
|
317
|
+
border: 0;
|
|
318
|
+
width: 1px;
|
|
319
|
+
height: 1px;
|
|
320
|
+
margin: -1px;
|
|
321
|
+
padding: 0;
|
|
322
|
+
position: absolute;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
}
|
|
325
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* Full ranking for one dashboard card, paged client-side.
|
|
10
|
+
*
|
|
11
|
+
* The dashboard cards show a short preview so the lists band stays even
|
|
12
|
+
* regardless of traffic. This modal shows the whole set behind a card's
|
|
13
|
+
* "View all" action. A `source` may be a ready list (countries are fully
|
|
14
|
+
* loaded with the dashboard) or a loader invoked once each time the modal
|
|
15
|
+
* opens (pages, downloads and referrers fetch a larger top-N on demand).
|
|
16
|
+
*
|
|
17
|
+
* The caption keeps the dashboard's honesty rule: when the loaded rows are
|
|
18
|
+
* a top-N slice of a larger set it says so alongside the page range.
|
|
19
|
+
*/
|
|
20
|
+
import type React from 'react';
|
|
21
|
+
import { type AnalyticsTone, type RankedListSource } from './ranking.js';
|
|
22
|
+
export type RankedListSourceInput = RankedListSource | (() => Promise<RankedListSource>);
|
|
23
|
+
export interface RankedListModalProps {
|
|
24
|
+
isOpen: boolean;
|
|
25
|
+
onDismiss(): void;
|
|
26
|
+
title: string;
|
|
27
|
+
tone: AnalyticsTone;
|
|
28
|
+
locale: string;
|
|
29
|
+
source: RankedListSourceInput;
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function RankedListModal({ isOpen, onDismiss, title, tone, locale, source, pageSize, }: RankedListModalProps): React.JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
+
import { Alert, Button, LoaderRing, Modal, Pagination } from "@byline/ui/react";
|
|
6
|
+
import clsx from "clsx";
|
|
7
|
+
import dashboard_module from "./dashboard.module.js";
|
|
8
|
+
import { RankingRows, pageWindow } from "./ranking.js";
|
|
9
|
+
function RankedListModal({ isOpen, onDismiss, title, tone, locale, source, pageSize = 25 }) {
|
|
10
|
+
const { t } = useTranslation('byline-admin');
|
|
11
|
+
const [state, setState] = useState({
|
|
12
|
+
status: 'idle'
|
|
13
|
+
});
|
|
14
|
+
const [page, setPage] = useState(1);
|
|
15
|
+
useEffect(()=>{
|
|
16
|
+
if (!isOpen) {
|
|
17
|
+
setState({
|
|
18
|
+
status: 'idle'
|
|
19
|
+
});
|
|
20
|
+
setPage(1);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if ('function' != typeof source) return void setState({
|
|
24
|
+
status: 'ready',
|
|
25
|
+
data: source
|
|
26
|
+
});
|
|
27
|
+
let current = true;
|
|
28
|
+
setState({
|
|
29
|
+
status: 'loading'
|
|
30
|
+
});
|
|
31
|
+
source().then((data)=>{
|
|
32
|
+
if (current) setState({
|
|
33
|
+
status: 'ready',
|
|
34
|
+
data
|
|
35
|
+
});
|
|
36
|
+
}).catch(()=>{
|
|
37
|
+
if (current) setState({
|
|
38
|
+
status: 'error'
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return ()=>{
|
|
42
|
+
current = false;
|
|
43
|
+
};
|
|
44
|
+
}, [
|
|
45
|
+
isOpen,
|
|
46
|
+
source
|
|
47
|
+
]);
|
|
48
|
+
const data = 'ready' === state.status ? state.data : void 0;
|
|
49
|
+
const loaded = data?.rows.length ?? 0;
|
|
50
|
+
const window = pageWindow(loaded, pageSize, page);
|
|
51
|
+
const visible = data?.rows.slice(window.start, window.end) ?? [];
|
|
52
|
+
const truncated = null != data && data.total > loaded;
|
|
53
|
+
const caption = data ? [
|
|
54
|
+
0 === loaded ? void 0 : t('analytics.range', {
|
|
55
|
+
from: window.start + 1,
|
|
56
|
+
to: window.end,
|
|
57
|
+
total: loaded
|
|
58
|
+
}),
|
|
59
|
+
truncated ? t('analytics.topOf', {
|
|
60
|
+
shown: loaded,
|
|
61
|
+
total: data.total
|
|
62
|
+
}) : void 0
|
|
63
|
+
].filter((value)=>null != value).join(' · ') : void 0;
|
|
64
|
+
return /*#__PURE__*/ jsx(Modal, {
|
|
65
|
+
isOpen: isOpen,
|
|
66
|
+
onDismiss: onDismiss,
|
|
67
|
+
closeOnOverlayClick: true,
|
|
68
|
+
children: /*#__PURE__*/ jsxs(Modal.Container, {
|
|
69
|
+
className: clsx('byline-analytics-modal', dashboard_module.modal),
|
|
70
|
+
children: [
|
|
71
|
+
/*#__PURE__*/ jsxs(Modal.Header, {
|
|
72
|
+
children: [
|
|
73
|
+
/*#__PURE__*/ jsx("h2", {
|
|
74
|
+
children: title
|
|
75
|
+
}),
|
|
76
|
+
null != caption && caption.length > 0 && /*#__PURE__*/ jsx("p", {
|
|
77
|
+
className: clsx('muted', 'byline-analytics-modal-caption', dashboard_module.modalCaption),
|
|
78
|
+
children: caption
|
|
79
|
+
})
|
|
80
|
+
]
|
|
81
|
+
}),
|
|
82
|
+
/*#__PURE__*/ jsxs(Modal.Content, {
|
|
83
|
+
className: clsx('byline-analytics-modal-content', dashboard_module.modalContent),
|
|
84
|
+
children: [
|
|
85
|
+
'loading' === state.status && /*#__PURE__*/ jsxs("div", {
|
|
86
|
+
role: "status",
|
|
87
|
+
"aria-live": "polite",
|
|
88
|
+
className: dashboard_module.modalStatus,
|
|
89
|
+
children: [
|
|
90
|
+
/*#__PURE__*/ jsx(LoaderRing, {
|
|
91
|
+
size: 28,
|
|
92
|
+
"aria-hidden": "true"
|
|
93
|
+
}),
|
|
94
|
+
/*#__PURE__*/ jsx("span", {
|
|
95
|
+
className: dashboard_module.srOnly,
|
|
96
|
+
children: t('common.loading')
|
|
97
|
+
})
|
|
98
|
+
]
|
|
99
|
+
}),
|
|
100
|
+
'error' === state.status && /*#__PURE__*/ jsx("div", {
|
|
101
|
+
role: "alert",
|
|
102
|
+
children: /*#__PURE__*/ jsx(Alert, {
|
|
103
|
+
intent: "danger",
|
|
104
|
+
close: false,
|
|
105
|
+
children: t('analytics.loadError')
|
|
106
|
+
})
|
|
107
|
+
}),
|
|
108
|
+
null != data && 0 === loaded && /*#__PURE__*/ jsx("p", {
|
|
109
|
+
className: "muted",
|
|
110
|
+
children: t('analytics.empty')
|
|
111
|
+
}),
|
|
112
|
+
null != data && loaded > 0 && /*#__PURE__*/ jsx(RankingRows, {
|
|
113
|
+
rows: visible,
|
|
114
|
+
tone: tone,
|
|
115
|
+
locale: locale
|
|
116
|
+
})
|
|
117
|
+
]
|
|
118
|
+
}),
|
|
119
|
+
/*#__PURE__*/ jsxs(Modal.Actions, {
|
|
120
|
+
className: clsx('byline-analytics-modal-actions', dashboard_module.modalActions),
|
|
121
|
+
children: [
|
|
122
|
+
window.pageCount > 1 ? /*#__PURE__*/ jsx(Pagination, {
|
|
123
|
+
variant: "dashboard",
|
|
124
|
+
count: window.pageCount,
|
|
125
|
+
page: Math.min(page, window.pageCount),
|
|
126
|
+
onChange: (_event, next)=>setPage(next),
|
|
127
|
+
children: /*#__PURE__*/ jsx(Pagination.Root, {
|
|
128
|
+
ariaLabel: t('analytics.pager'),
|
|
129
|
+
children: /*#__PURE__*/ jsx(Pagination.Pager, {})
|
|
130
|
+
})
|
|
131
|
+
}) : /*#__PURE__*/ jsx("span", {}),
|
|
132
|
+
/*#__PURE__*/ jsx(Button, {
|
|
133
|
+
type: "button",
|
|
134
|
+
size: "sm",
|
|
135
|
+
variant: "outlined",
|
|
136
|
+
onClick: onDismiss,
|
|
137
|
+
children: t('common.actions.close')
|
|
138
|
+
})
|
|
139
|
+
]
|
|
140
|
+
})
|
|
141
|
+
]
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export { RankedListModal };
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
* Ranked-row rendering shared by the dashboard cards and the full-list
|
|
10
|
+
* modal, plus the small pure helpers both rely on.
|
|
11
|
+
*/
|
|
12
|
+
import type React from 'react';
|
|
13
|
+
export type AnalyticsTone = 'views' | 'visitors' | 'downloads';
|
|
14
|
+
export interface RankedRow {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
value: number;
|
|
18
|
+
visitors: number;
|
|
19
|
+
overflow: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** A complete ranking plus the size of the set it was drawn from. */
|
|
22
|
+
export interface RankedListSource {
|
|
23
|
+
rows: RankedRow[];
|
|
24
|
+
total: number;
|
|
25
|
+
}
|
|
26
|
+
/** The ordered list with a share bar behind each row. Renders no card chrome. */
|
|
27
|
+
export declare function RankingRows({ rows, tone, locale, }: {
|
|
28
|
+
rows: RankedRow[];
|
|
29
|
+
tone: AnalyticsTone;
|
|
30
|
+
locale: string;
|
|
31
|
+
}): React.JSX.Element;
|
|
32
|
+
/** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
|
|
33
|
+
export declare function shareWidth(value: number, ceiling: number): number;
|
|
34
|
+
/** Row indices for one page of a list, with the page clamped into range. */
|
|
35
|
+
export declare function pageWindow(total: number, pageSize: number, page: number): {
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
pageCount: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Localised region name for an ISO 3166-1 alpha-2 code, or the code itself
|
|
42
|
+
* when the value is not a code or the runtime has no name for it. The
|
|
43
|
+
* dashboard stores bare codes, so this is purely a display concern.
|
|
44
|
+
*/
|
|
45
|
+
export declare function regionName(code: string, locale: string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
import dashboard_module from "./dashboard.module.js";
|
|
7
|
+
const TONE_BAR = {
|
|
8
|
+
views: dashboard_module.barViews,
|
|
9
|
+
visitors: dashboard_module.barVisitors,
|
|
10
|
+
downloads: dashboard_module.barDownloads
|
|
11
|
+
};
|
|
12
|
+
function RankingRows({ rows, tone, locale }) {
|
|
13
|
+
const { t } = useTranslation('byline-admin');
|
|
14
|
+
const numbers = useMemo(()=>new Intl.NumberFormat(locale), [
|
|
15
|
+
locale
|
|
16
|
+
]);
|
|
17
|
+
const ceiling = Math.max(1, ...rows.map((row)=>row.value));
|
|
18
|
+
return /*#__PURE__*/ jsx("ol", {
|
|
19
|
+
className: clsx('byline-analytics-ranking', dashboard_module.ranking),
|
|
20
|
+
children: rows.map((row)=>/*#__PURE__*/ jsxs("li", {
|
|
21
|
+
className: clsx('byline-analytics-ranking-row', dashboard_module.rankingRow, TONE_BAR[tone], row.overflow && dashboard_module.rankingOverflow),
|
|
22
|
+
style: {
|
|
23
|
+
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`
|
|
24
|
+
},
|
|
25
|
+
children: [
|
|
26
|
+
/*#__PURE__*/ jsx("span", {
|
|
27
|
+
className: dashboard_module.rankingLabel,
|
|
28
|
+
title: row.key,
|
|
29
|
+
children: row.overflow ? t('analytics.overflow') : row.label
|
|
30
|
+
}),
|
|
31
|
+
/*#__PURE__*/ jsx("span", {
|
|
32
|
+
className: dashboard_module.rankingValue,
|
|
33
|
+
children: numbers.format(row.value)
|
|
34
|
+
}),
|
|
35
|
+
/*#__PURE__*/ jsx("span", {
|
|
36
|
+
className: dashboard_module.rankingVisitors,
|
|
37
|
+
children: numbers.format(row.visitors)
|
|
38
|
+
})
|
|
39
|
+
]
|
|
40
|
+
}, row.key))
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function shareWidth(value, ceiling) {
|
|
44
|
+
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0;
|
|
45
|
+
return Math.max(3, Math.min(100, value / ceiling * 100));
|
|
46
|
+
}
|
|
47
|
+
function pageWindow(total, pageSize, page) {
|
|
48
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
49
|
+
const current = Math.min(Math.max(1, page), pageCount);
|
|
50
|
+
const start = (current - 1) * pageSize;
|
|
51
|
+
return {
|
|
52
|
+
start,
|
|
53
|
+
end: Math.min(total, start + pageSize),
|
|
54
|
+
pageCount
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function regionName(code, locale) {
|
|
58
|
+
if (!/^[A-Za-z]{2}$/.test(code)) return code;
|
|
59
|
+
try {
|
|
60
|
+
const names = new Intl.DisplayNames([
|
|
61
|
+
locale
|
|
62
|
+
], {
|
|
63
|
+
type: 'region',
|
|
64
|
+
fallback: 'none'
|
|
65
|
+
});
|
|
66
|
+
return names.of(code.toUpperCase()) ?? code;
|
|
67
|
+
} catch {
|
|
68
|
+
return code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export { RankingRows, pageWindow, regionName, shareWidth };
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
4
|
+
import cx from 'clsx';
|
|
5
|
+
import styles from './timeseries.module.css';
|
|
6
|
+
/** Drawn in a fixed user-space box and stretched to the container width. */
|
|
7
|
+
const VIEWBOX_WIDTH = 900;
|
|
8
|
+
const VIEWBOX_HEIGHT = 180;
|
|
9
|
+
const COLUMN_GAP_RATIO = 0.38;
|
|
10
|
+
const MAX_COLUMN_WIDTH = 26;
|
|
11
|
+
/** Resolve chart density outside the renderer so the chosen width is explicit. */
|
|
12
|
+
export function resolveAnalyticsChartGranularity(period, dayCount) {
|
|
13
|
+
if (typeof period === 'number' || (period === 'all' && dayCount <= 90))
|
|
14
|
+
return 'day';
|
|
15
|
+
if (period === 'ytd' || dayCount <= 732)
|
|
16
|
+
return 'seven-day';
|
|
17
|
+
return 'month';
|
|
18
|
+
}
|
|
19
|
+
/** Combine complete daily query rows using the caller-selected granularity. */
|
|
20
|
+
export function bucketAnalyticsTimeseries(days, granularity) {
|
|
21
|
+
if (granularity === 'day') {
|
|
22
|
+
return days.map((day) => ({
|
|
23
|
+
from: day.day,
|
|
24
|
+
to: day.day,
|
|
25
|
+
granularity,
|
|
26
|
+
dayCount: 1,
|
|
27
|
+
views: day.views,
|
|
28
|
+
visitors: day.visitors,
|
|
29
|
+
downloads: day.downloads,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
if (granularity === 'seven-day')
|
|
33
|
+
return bucketBySevenDays(days);
|
|
34
|
+
return bucketByUtcMonth(days);
|
|
35
|
+
}
|
|
36
|
+
/** Project reporting buckets into column geometry. */
|
|
37
|
+
export function buildAnalyticsColumns(buckets) {
|
|
38
|
+
if (buckets.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
const ceiling = Math.max(1, ...buckets.map((bucket) => bucket.views));
|
|
41
|
+
const step = VIEWBOX_WIDTH / buckets.length;
|
|
42
|
+
const width = Math.max(2, Math.min(step * (1 - COLUMN_GAP_RATIO), MAX_COLUMN_WIDTH));
|
|
43
|
+
return buckets.map((bucket, index) => {
|
|
44
|
+
const centre = index * step + step / 2;
|
|
45
|
+
const height = (bucket.views / ceiling) * VIEWBOX_HEIGHT;
|
|
46
|
+
const insetHeight = (bucket.visitors / ceiling) * VIEWBOX_HEIGHT;
|
|
47
|
+
const insetWidth = Math.max(1, width / 2);
|
|
48
|
+
return {
|
|
49
|
+
index,
|
|
50
|
+
x: centre - width / 2,
|
|
51
|
+
width,
|
|
52
|
+
y: VIEWBOX_HEIGHT - height,
|
|
53
|
+
height,
|
|
54
|
+
insetX: centre - insetWidth / 2,
|
|
55
|
+
insetWidth,
|
|
56
|
+
insetY: VIEWBOX_HEIGHT - insetHeight,
|
|
57
|
+
insetHeight,
|
|
58
|
+
hitX: index * step,
|
|
59
|
+
hitWidth: step,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function AnalyticsTimeseries({ days, granularity, locale, }) {
|
|
64
|
+
const { t } = useTranslation('byline-admin');
|
|
65
|
+
const [hovered, setHovered] = useState(null);
|
|
66
|
+
const plot = useRef(null);
|
|
67
|
+
const buckets = useMemo(() => bucketAnalyticsTimeseries(days, granularity), [days, granularity]);
|
|
68
|
+
const columns = useMemo(() => buildAnalyticsColumns(buckets), [buckets]);
|
|
69
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
70
|
+
// Stored days are UTC calendar days. Formatting in the viewer's zone would
|
|
71
|
+
// shift the labels by up to one day.
|
|
72
|
+
const dayLabel = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }), [locale]);
|
|
73
|
+
const formatDay = (day) => dayLabel.format(new Date(`${day}T00:00:00.000Z`));
|
|
74
|
+
const formatBucket = (bucket) => bucket.dayCount === 1
|
|
75
|
+
? formatDay(bucket.from)
|
|
76
|
+
: `${formatDay(bucket.from)} – ${formatDay(bucket.to)}`;
|
|
77
|
+
const visitorLabel = (bucket) => bucket.granularity === 'day'
|
|
78
|
+
? t('analytics.stats.dailyUniques')
|
|
79
|
+
: t('analytics.stats.summedDailyUniques');
|
|
80
|
+
const active = hovered == null ? undefined : buckets[hovered];
|
|
81
|
+
const readBucket = (bucket) => [
|
|
82
|
+
formatBucket(bucket),
|
|
83
|
+
`${t('analytics.stats.views')} ${numbers.format(bucket.views)}`,
|
|
84
|
+
`${visitorLabel(bucket)} ${numbers.format(bucket.visitors)}`,
|
|
85
|
+
`${t('analytics.stats.downloads')} ${numbers.format(bucket.downloads)}`,
|
|
86
|
+
].join(', ');
|
|
87
|
+
// One handler on the plot rather than one per column keeps every reporting
|
|
88
|
+
// range to a single pointer listener and one keyboard tab stop.
|
|
89
|
+
const trackPointer = (event) => {
|
|
90
|
+
if (buckets.length === 0 || plot.current == null)
|
|
91
|
+
return;
|
|
92
|
+
const bounds = plot.current.getBoundingClientRect();
|
|
93
|
+
if (bounds.width === 0)
|
|
94
|
+
return;
|
|
95
|
+
const ratio = (event.clientX - bounds.left) / bounds.width;
|
|
96
|
+
setHovered(Math.min(buckets.length - 1, Math.max(0, Math.floor(ratio * buckets.length))));
|
|
97
|
+
};
|
|
98
|
+
const trackKey = (event) => {
|
|
99
|
+
if (buckets.length === 0)
|
|
100
|
+
return;
|
|
101
|
+
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
|
102
|
+
if (step === 0 && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Escape')
|
|
103
|
+
return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
if (event.key === 'Escape')
|
|
106
|
+
return setHovered(null);
|
|
107
|
+
if (event.key === 'Home')
|
|
108
|
+
return setHovered(0);
|
|
109
|
+
if (event.key === 'End')
|
|
110
|
+
return setHovered(buckets.length - 1);
|
|
111
|
+
setHovered((current) => {
|
|
112
|
+
const next = (current ?? (step > 0 ? -1 : buckets.length)) + step;
|
|
113
|
+
return Math.min(buckets.length - 1, Math.max(0, next));
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
return (<div className={cx('byline-analytics-timeseries', styles.root)} role="slider" tabIndex={0} aria-label={t('analytics.chart.views')} aria-valuemin={0} aria-valuemax={Math.max(0, buckets.length - 1)} aria-valuenow={hovered ?? 0} aria-valuetext={active == null ? t('analytics.chart.hint') : readBucket(active)} onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)} onKeyDown={trackKey} onBlur={() => setHovered(null)}>
|
|
117
|
+
<svg ref={plot} className={cx('byline-analytics-chart', styles.chart)} viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`} preserveAspectRatio="none" aria-hidden="true">
|
|
118
|
+
<title>{t('analytics.chart.views')}</title>
|
|
119
|
+
{[0.5, 1].map((fraction) => (<line key={fraction} className={styles.gridline} x1={0} x2={VIEWBOX_WIDTH} y1={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction} y2={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction} vectorEffect="non-scaling-stroke"/>))}
|
|
120
|
+
{columns.map((column) => (<g key={buckets[column.index]?.from} className={cx(styles.column, hovered === column.index && styles.columnActive)}>
|
|
121
|
+
<rect className={styles.views} x={column.x} width={column.width} y={column.y} height={column.height}/>
|
|
122
|
+
<rect className={styles.visitors} x={column.insetX} width={column.insetWidth} y={column.insetY} height={column.insetHeight}/>
|
|
123
|
+
</g>))}
|
|
124
|
+
</svg>
|
|
125
|
+
|
|
126
|
+
{/* HTML axis labels remain undistorted while the SVG stretches. */}
|
|
127
|
+
<div className={cx('byline-analytics-axis', styles.axis)} aria-hidden="true">
|
|
128
|
+
<span>{buckets.length > 0 && buckets[0] != null ? formatDay(buckets[0].from) : ''}</span>
|
|
129
|
+
<span>
|
|
130
|
+
{buckets.length > 0 && buckets[buckets.length - 1] != null
|
|
131
|
+
? formatDay(buckets[buckets.length - 1]?.to ?? '')
|
|
132
|
+
: ''}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<p className={cx('byline-analytics-readout', styles.readout)} aria-live="polite">
|
|
137
|
+
{active == null ? (t('analytics.chart.hint')) : (<>
|
|
138
|
+
<strong>{formatBucket(active)}</strong>
|
|
139
|
+
<span className={styles.swatchViews}/>
|
|
140
|
+
{t('analytics.stats.views')} {numbers.format(active.views)}
|
|
141
|
+
<span className={styles.swatchVisitors}/>
|
|
142
|
+
{visitorLabel(active)} {numbers.format(active.visitors)}
|
|
143
|
+
<span className={styles.swatchDownloads}/>
|
|
144
|
+
{t('analytics.stats.downloads')} {numbers.format(active.downloads)}
|
|
145
|
+
</>)}
|
|
146
|
+
</p>
|
|
147
|
+
</div>);
|
|
148
|
+
}
|
|
149
|
+
function bucketBySevenDays(days) {
|
|
150
|
+
const buckets = [];
|
|
151
|
+
for (let index = 0; index < days.length; index += 7) {
|
|
152
|
+
const rows = days.slice(index, index + 7);
|
|
153
|
+
const first = rows[0];
|
|
154
|
+
const last = rows[rows.length - 1];
|
|
155
|
+
if (first != null && last != null) {
|
|
156
|
+
buckets.push(combineRows(first.day, last.day, 'seven-day', rows));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return buckets;
|
|
160
|
+
}
|
|
161
|
+
function bucketByUtcMonth(days) {
|
|
162
|
+
const buckets = [];
|
|
163
|
+
let rows = [];
|
|
164
|
+
let month;
|
|
165
|
+
const flush = () => {
|
|
166
|
+
const first = rows[0];
|
|
167
|
+
const last = rows[rows.length - 1];
|
|
168
|
+
if (first != null && last != null) {
|
|
169
|
+
buckets.push(combineRows(first.day, last.day, 'month', rows));
|
|
170
|
+
}
|
|
171
|
+
rows = [];
|
|
172
|
+
};
|
|
173
|
+
for (const day of days) {
|
|
174
|
+
const nextMonth = day.day.slice(0, 7);
|
|
175
|
+
if (month != null && nextMonth !== month)
|
|
176
|
+
flush();
|
|
177
|
+
month = nextMonth;
|
|
178
|
+
rows.push(day);
|
|
179
|
+
}
|
|
180
|
+
flush();
|
|
181
|
+
return buckets;
|
|
182
|
+
}
|
|
183
|
+
function combineRows(from, to, granularity, rows) {
|
|
184
|
+
let views = 0;
|
|
185
|
+
let visitors = 0;
|
|
186
|
+
let downloads = 0;
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
views += row.views;
|
|
189
|
+
visitors += row.visitors;
|
|
190
|
+
downloads += row.downloads;
|
|
191
|
+
}
|
|
192
|
+
return { from, to, granularity, dayCount: rows.length, views, visitors, downloads };
|
|
193
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) Infonomic Company Limited
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Admin sign-in form.
|
|
11
|
+
*
|
|
12
|
+
* Client component — collects email + password, calls the `adminSignIn`
|
|
13
|
+
* server fn, and on success navigates to a safe caller-supplied destination.
|
|
14
|
+
* On failure renders a generic "Invalid
|
|
15
|
+
* credentials" alert; the provider equalises timing between
|
|
16
|
+
* unknown-email and wrong-password so the UI doesn't distinguish the two.
|
|
17
|
+
*
|
|
18
|
+
* Stable override handles: `.byline-sign-in-card`, `.byline-sign-in-alert`,
|
|
19
|
+
* `.byline-sign-in-form`, `.byline-sign-in-fields`,
|
|
20
|
+
* `.byline-sign-in-actions`, `.byline-sign-in-button`,
|
|
21
|
+
* `.byline-sign-in-home-link`.
|
|
22
|
+
*/
|
|
23
|
+
import { useState } from 'react';
|
|
24
|
+
import { getAdminConfig } from '@byline/core';
|
|
25
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
26
|
+
import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react';
|
|
27
|
+
import cx from 'clsx';
|
|
28
|
+
import { useBylineAdminServices } from '../../../services/admin-services-context.js';
|
|
29
|
+
import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from '../safe-redirect.js';
|
|
30
|
+
import styles from './sign-in-form.module.css';
|
|
31
|
+
export function SignInForm({ redirectTo, homeUrl }) {
|
|
32
|
+
const { adminSignIn } = useBylineAdminServices();
|
|
33
|
+
const { t } = useTranslation('byline-admin');
|
|
34
|
+
const [email, setEmail] = useState('');
|
|
35
|
+
const [password, setPassword] = useState('');
|
|
36
|
+
const [pending, setPending] = useState(false);
|
|
37
|
+
const [error, setError] = useState(null);
|
|
38
|
+
async function handleSubmit(event) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
if (pending)
|
|
41
|
+
return;
|
|
42
|
+
if (email.trim().length === 0 || password.length === 0) {
|
|
43
|
+
setError(t('auth.signIn.errors.empty'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const destination = resolveSignInFormRedirect(redirectTo, getAdminConfig().routes.admin);
|
|
47
|
+
setPending(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
try {
|
|
50
|
+
await adminSignIn({ data: { email: email.trim(), password } });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
console.warn('sign-in failed', err);
|
|
54
|
+
setError(t('auth.signIn.errors.invalidCredentials'));
|
|
55
|
+
setPending(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Keep navigation outside the credential error boundary. A browser-level
|
|
59
|
+
// navigation failure must not relabel a successful sign-in as bad credentials.
|
|
60
|
+
window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/');
|
|
61
|
+
}
|
|
62
|
+
return (<Card className={cx('byline-sign-in-card', styles.card)}>
|
|
63
|
+
<Card.Header>
|
|
64
|
+
<Card.Title>
|
|
65
|
+
<h2>{t('auth.signIn.title')}</h2>
|
|
66
|
+
</Card.Title>
|
|
67
|
+
<Card.Description>{t('auth.signIn.description')}</Card.Description>
|
|
68
|
+
{error && (<Alert intent="danger" className={cx('byline-sign-in-alert', styles.alert)}>
|
|
69
|
+
{error}
|
|
70
|
+
</Alert>)}
|
|
71
|
+
</Card.Header>
|
|
72
|
+
<Card.Content>
|
|
73
|
+
<form method="post" onSubmit={handleSubmit} noValidate className={cx('byline-sign-in-form', styles.form)}>
|
|
74
|
+
<div className={cx('byline-sign-in-fields', styles.fields)}>
|
|
75
|
+
<Input label={t('common.fields.email')} id="email" name="email" type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} disabled={pending}/>
|
|
76
|
+
<Input label={t('common.fields.password')} id="password" name="password" type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.currentTarget.value)} disabled={pending}/>
|
|
77
|
+
</div>
|
|
78
|
+
<div className={cx('byline-sign-in-actions', styles.actions)}>
|
|
79
|
+
{homeUrl && (<a href={homeUrl} className={cx('byline-sign-in-home-link', styles['home-link'])}>
|
|
80
|
+
{t('common.actions.home')}
|
|
81
|
+
</a>)}
|
|
82
|
+
<Button type="submit" disabled={pending} className={cx('byline-sign-in-button', styles.button)}>
|
|
83
|
+
{pending ? (<LoaderEllipsis size={30} color="#aaaaaa"/>) : (<span>{t('common.actions.signIn')}</span>)}
|
|
84
|
+
</Button>
|
|
85
|
+
</div>
|
|
86
|
+
</form>
|
|
87
|
+
</Card.Content>
|
|
88
|
+
</Card>);
|
|
89
|
+
}
|