@byline/admin 4.14.1 → 4.16.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.
- package/dist/abilities.js +2 -0
- package/dist/forms/document-actions.d.ts +16 -1
- package/dist/forms/document-actions.js +43 -1
- package/dist/forms/form-renderer.d.ts +6 -1
- package/dist/forms/form-renderer.js +39 -36
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +7 -1
- package/dist/forms/form-status-display.d.ts +10 -1
- package/dist/forms/form-status-display.js +4 -2
- package/dist/forms/scheduled-publication-control.d.ts +56 -0
- package/dist/forms/scheduled-publication-control.js +376 -0
- package/dist/forms/scheduled-publication-control.module.js +25 -0
- package/dist/forms/scheduled-publication-control_module.css +95 -0
- package/dist/forms/scheduled-publication-state.d.ts +83 -0
- package/dist/forms/scheduled-publication-state.js +41 -0
- package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
- package/dist/forms/scheduled-publication-time.d.ts +57 -0
- package/dist/forms/scheduled-publication-time.js +113 -0
- package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics/abilities.d.ts +14 -0
- package/dist/modules/analytics/abilities.js +21 -0
- package/dist/modules/analytics/components/dashboard.d.ts +20 -0
- package/dist/modules/analytics/components/dashboard.js +308 -0
- package/dist/modules/analytics/components/dashboard.module.js +29 -0
- package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/dashboard_module.css +268 -0
- package/dist/modules/analytics/components/timeseries.d.ts +59 -0
- package/dist/modules/analytics/components/timeseries.js +253 -0
- package/dist/modules/analytics/components/timeseries.module.js +17 -0
- package/dist/modules/analytics/components/timeseries_module.css +126 -0
- package/dist/modules/analytics/index.d.ts +9 -0
- package/dist/modules/analytics/index.js +1 -0
- package/dist/modules/analytics/types.d.ts +20 -0
- package/dist/modules/analytics/types.js +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +23 -7
- package/src/abilities.ts +2 -0
- package/src/forms/document-actions.tsx +73 -0
- package/src/forms/form-renderer.module.css +15 -0
- package/src/forms/form-renderer.tsx +59 -64
- package/src/forms/form-status-display.tsx +12 -0
- package/src/forms/path-widget.test.tsx +20 -8
- package/src/forms/scheduled-publication-control.module.css +149 -0
- package/src/forms/scheduled-publication-control.tsx +590 -0
- package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
- package/src/forms/scheduled-publication-state.test.node.ts +190 -0
- package/src/forms/scheduled-publication-state.ts +146 -0
- package/src/forms/scheduled-publication-time.test.node.ts +86 -0
- package/src/forms/scheduled-publication-time.ts +218 -0
- package/src/index.ts +1 -0
- package/src/modules/analytics/abilities.ts +33 -0
- package/src/modules/analytics/components/dashboard.module.css +330 -0
- package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
- package/src/modules/analytics/components/dashboard.tsx +371 -0
- package/src/modules/analytics/components/timeseries.module.css +151 -0
- package/src/modules/analytics/components/timeseries.tsx +332 -0
- package/src/modules/analytics/index.ts +14 -0
- package/src/modules/analytics/types.ts +31 -0
- package/src/react.ts +3 -0
|
@@ -0,0 +1,371 @@
|
|
|
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 type React from 'react'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ANALYTICS_DASHBOARD_PERIODS,
|
|
16
|
+
ANALYTICS_OVERFLOW_KEY,
|
|
17
|
+
isAnalyticsDashboardPeriod,
|
|
18
|
+
} from '@byline/analytics/config'
|
|
19
|
+
import { ANALYTICS_IGNORE_STORAGE_KEY } from '@byline/analytics-agent'
|
|
20
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
21
|
+
import { Button, Card, Container, Section, Select } from '@byline/ui/react'
|
|
22
|
+
import cx from 'clsx'
|
|
23
|
+
|
|
24
|
+
import styles from './dashboard.module.css'
|
|
25
|
+
import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from './timeseries.js'
|
|
26
|
+
import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '../types.js'
|
|
27
|
+
|
|
28
|
+
export interface AnalyticsDashboardProps {
|
|
29
|
+
data: AnalyticsDashboardData
|
|
30
|
+
period: AnalyticsDashboardPeriod
|
|
31
|
+
onPeriodChange(period: AnalyticsDashboardPeriod): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AnalyticsDashboard({
|
|
35
|
+
data,
|
|
36
|
+
period,
|
|
37
|
+
onPeriodChange,
|
|
38
|
+
}: AnalyticsDashboardProps): React.JSX.Element {
|
|
39
|
+
const { locale, t } = useTranslation('byline-admin')
|
|
40
|
+
const [excluded, setExcluded] = useState(false)
|
|
41
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
try {
|
|
45
|
+
setExcluded(localStorage.getItem(ANALYTICS_IGNORE_STORAGE_KEY) != null)
|
|
46
|
+
} catch {
|
|
47
|
+
setExcluded(false)
|
|
48
|
+
}
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const toggleExclusion = () => {
|
|
52
|
+
try {
|
|
53
|
+
if (excluded) localStorage.removeItem(ANALYTICS_IGNORE_STORAGE_KEY)
|
|
54
|
+
else localStorage.setItem(ANALYTICS_IGNORE_STORAGE_KEY, '1')
|
|
55
|
+
setExcluded(!excluded)
|
|
56
|
+
} catch {
|
|
57
|
+
// A blocked storage surface leaves the current collection behavior unchanged.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const periodItems = ANALYTICS_DASHBOARD_PERIODS.map((value) => ({
|
|
62
|
+
value: String(value),
|
|
63
|
+
label:
|
|
64
|
+
typeof value === 'number'
|
|
65
|
+
? t('analytics.period.days', { count: value })
|
|
66
|
+
: t(`analytics.period.${value}`),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const { views, visitors, downloads } = data.summary
|
|
70
|
+
const days = data.summary.timeseries.length
|
|
71
|
+
const chartGranularity = resolveAnalyticsChartGranularity(period, days)
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Section>
|
|
75
|
+
<Container>
|
|
76
|
+
<header className={cx('byline-analytics-header', styles.header)}>
|
|
77
|
+
<div>
|
|
78
|
+
<h1 className={cx('byline-analytics-title', styles.title)}>{t('analytics.title')}</h1>
|
|
79
|
+
<p className={cx('muted', 'byline-analytics-help', styles.help)}>
|
|
80
|
+
{t('analytics.dailyUniquesHelp')}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className={cx('byline-analytics-controls', styles.controls)}>
|
|
84
|
+
<Select<string>
|
|
85
|
+
id="analytics-period"
|
|
86
|
+
name="analytics-period"
|
|
87
|
+
aria-label={t('analytics.period.label')}
|
|
88
|
+
size="sm"
|
|
89
|
+
value={String(period)}
|
|
90
|
+
items={periodItems}
|
|
91
|
+
onValueChange={(value) => {
|
|
92
|
+
const next = value === 'ytd' || value === 'all' ? value : Number(value)
|
|
93
|
+
if (isAnalyticsDashboardPeriod(next)) onPeriodChange(next)
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
size="sm"
|
|
99
|
+
aria-pressed={excluded}
|
|
100
|
+
// Local storage is origin-scoped, so this only governs public-page
|
|
101
|
+
// collection when the admin and the public site share an origin.
|
|
102
|
+
title={t('analytics.exclusion.help')}
|
|
103
|
+
onClick={toggleExclusion}
|
|
104
|
+
>
|
|
105
|
+
{excluded ? t('analytics.exclusion.include') : t('analytics.exclusion.exclude')}
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</header>
|
|
109
|
+
|
|
110
|
+
{/* Tinted ground, saturated ink, tracked label, tabular number — the
|
|
111
|
+
same tile grammar as the collection dashboard's status counts. */}
|
|
112
|
+
<div className={cx('byline-analytics-stats', styles.stats)}>
|
|
113
|
+
<StatTile
|
|
114
|
+
tone="views"
|
|
115
|
+
label={t('analytics.stats.views')}
|
|
116
|
+
value={numbers.format(views)}
|
|
117
|
+
foot={t('analytics.stats.perDay', {
|
|
118
|
+
count: days === 0 ? 0 : Math.round(views / days),
|
|
119
|
+
})}
|
|
120
|
+
/>
|
|
121
|
+
<StatTile
|
|
122
|
+
tone="visitors"
|
|
123
|
+
label={t('analytics.stats.dailyUniques')}
|
|
124
|
+
value={numbers.format(visitors)}
|
|
125
|
+
// The qualification rides under the figure it qualifies rather
|
|
126
|
+
// than sitting in help text several elements away.
|
|
127
|
+
foot={t('analytics.stats.sumOfDays', { count: days })}
|
|
128
|
+
/>
|
|
129
|
+
<StatTile
|
|
130
|
+
tone="downloads"
|
|
131
|
+
label={t('analytics.stats.downloads')}
|
|
132
|
+
value={numbers.format(downloads)}
|
|
133
|
+
foot={t('analytics.stats.shareOfViews', {
|
|
134
|
+
share: formatShare(downloads, views, locale),
|
|
135
|
+
})}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<Card className={cx('byline-analytics-chart-card', styles.chartCard)}>
|
|
140
|
+
<Card.Header>
|
|
141
|
+
<Card.Title>
|
|
142
|
+
{chartGranularity === 'day'
|
|
143
|
+
? t('analytics.chart.perDay')
|
|
144
|
+
: chartGranularity === 'seven-day'
|
|
145
|
+
? t('analytics.chart.perSevenDays')
|
|
146
|
+
: t('analytics.chart.perMonth')}
|
|
147
|
+
</Card.Title>
|
|
148
|
+
</Card.Header>
|
|
149
|
+
<Card.Content>
|
|
150
|
+
<AnalyticsTimeseries
|
|
151
|
+
days={data.summary.timeseries}
|
|
152
|
+
granularity={chartGranularity}
|
|
153
|
+
locale={locale}
|
|
154
|
+
/>
|
|
155
|
+
</Card.Content>
|
|
156
|
+
</Card>
|
|
157
|
+
|
|
158
|
+
{/* Top pages is the list people actually read, so it gets the wide
|
|
159
|
+
column; referrers and countries stack beside it. */}
|
|
160
|
+
<div className={cx('byline-analytics-lists', styles.lists)}>
|
|
161
|
+
<RankedList
|
|
162
|
+
title={t('analytics.sections.pages')}
|
|
163
|
+
caption={t('analytics.columns.viewsAndUniques')}
|
|
164
|
+
tone="views"
|
|
165
|
+
locale={locale}
|
|
166
|
+
rows={data.pages.rows.map(toPathRow)}
|
|
167
|
+
total={data.pages.total}
|
|
168
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
169
|
+
/>
|
|
170
|
+
<div className={styles.stack}>
|
|
171
|
+
<RankedList
|
|
172
|
+
title={t('analytics.sections.referrers')}
|
|
173
|
+
tone="visitors"
|
|
174
|
+
locale={locale}
|
|
175
|
+
total={data.referrers.total}
|
|
176
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.referrersFrom)}
|
|
177
|
+
rows={data.referrers.rows.map((row) => ({
|
|
178
|
+
key: row.referrerHost,
|
|
179
|
+
label: row.referrerHost,
|
|
180
|
+
value: row.views,
|
|
181
|
+
visitors: row.visitors,
|
|
182
|
+
overflow: row.referrerHost === ANALYTICS_OVERFLOW_KEY,
|
|
183
|
+
}))}
|
|
184
|
+
/>
|
|
185
|
+
<RankedList
|
|
186
|
+
title={t('analytics.sections.countries')}
|
|
187
|
+
tone="visitors"
|
|
188
|
+
locale={locale}
|
|
189
|
+
rows={data.countries.map((row) => ({
|
|
190
|
+
key: row.country,
|
|
191
|
+
label: row.country,
|
|
192
|
+
value: row.views,
|
|
193
|
+
visitors: row.visitors,
|
|
194
|
+
overflow: false,
|
|
195
|
+
}))}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<RankedList
|
|
201
|
+
title={t('analytics.sections.downloads')}
|
|
202
|
+
caption={t('analytics.columns.clicksAndUniques')}
|
|
203
|
+
tone="downloads"
|
|
204
|
+
locale={locale}
|
|
205
|
+
rows={data.downloads.rows.map(toPathRow)}
|
|
206
|
+
total={data.downloads.total}
|
|
207
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
208
|
+
/>
|
|
209
|
+
</Container>
|
|
210
|
+
</Section>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type AnalyticsTone = 'views' | 'visitors' | 'downloads'
|
|
215
|
+
|
|
216
|
+
const TONE_TILE: Record<AnalyticsTone, string | undefined> = {
|
|
217
|
+
views: styles.toneViews,
|
|
218
|
+
visitors: styles.toneVisitors,
|
|
219
|
+
downloads: styles.toneDownloads,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const TONE_BAR: Record<AnalyticsTone, string | undefined> = {
|
|
223
|
+
views: styles.barViews,
|
|
224
|
+
visitors: styles.barVisitors,
|
|
225
|
+
downloads: styles.barDownloads,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function StatTile({
|
|
229
|
+
tone,
|
|
230
|
+
label,
|
|
231
|
+
value,
|
|
232
|
+
foot,
|
|
233
|
+
}: {
|
|
234
|
+
tone: AnalyticsTone
|
|
235
|
+
label: string
|
|
236
|
+
value: string
|
|
237
|
+
foot: string
|
|
238
|
+
}): React.JSX.Element {
|
|
239
|
+
return (
|
|
240
|
+
<div className={cx('byline-analytics-stat', styles.stat, TONE_TILE[tone])}>
|
|
241
|
+
<span className={cx('byline-analytics-stat-label', styles.statLabel)}>{label}</span>
|
|
242
|
+
<span className={cx('byline-analytics-stat-value', styles.statValue)}>{value}</span>
|
|
243
|
+
<span className={cx('byline-analytics-stat-foot', styles.statFoot)}>{foot}</span>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface RankedRow {
|
|
249
|
+
key: string
|
|
250
|
+
label: string
|
|
251
|
+
value: number
|
|
252
|
+
visitors: number
|
|
253
|
+
overflow: boolean
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toPathRow(row: { path: string; views: number; visitors: number }): RankedRow {
|
|
257
|
+
return {
|
|
258
|
+
key: row.path,
|
|
259
|
+
label: row.path,
|
|
260
|
+
value: row.views,
|
|
261
|
+
visitors: row.visitors,
|
|
262
|
+
overflow: row.path === ANALYTICS_OVERFLOW_KEY,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function RankedList({
|
|
267
|
+
title,
|
|
268
|
+
caption,
|
|
269
|
+
rows,
|
|
270
|
+
tone,
|
|
271
|
+
locale,
|
|
272
|
+
total,
|
|
273
|
+
coverageFrom,
|
|
274
|
+
}: {
|
|
275
|
+
title: string
|
|
276
|
+
caption?: string
|
|
277
|
+
rows: RankedRow[]
|
|
278
|
+
tone: AnalyticsTone
|
|
279
|
+
locale: string
|
|
280
|
+
/** Distinct keys in the period; omit for lists that are never truncated. */
|
|
281
|
+
total?: number
|
|
282
|
+
/** First complete day when the selected report begins before retained rows. */
|
|
283
|
+
coverageFrom?: string
|
|
284
|
+
}): React.JSX.Element {
|
|
285
|
+
const { t } = useTranslation('byline-admin')
|
|
286
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
|
|
287
|
+
const ceiling = Math.max(1, ...rows.map((row) => row.value))
|
|
288
|
+
// Say so when the list is a top-N slice. Without this the card presents a
|
|
289
|
+
// truncated ranking as though it were the whole set.
|
|
290
|
+
const truncated = total != null && total > rows.length
|
|
291
|
+
const coverageDate = useMemo(
|
|
292
|
+
() =>
|
|
293
|
+
new Intl.DateTimeFormat(locale, {
|
|
294
|
+
dateStyle: 'medium',
|
|
295
|
+
timeZone: 'UTC',
|
|
296
|
+
}),
|
|
297
|
+
[locale]
|
|
298
|
+
)
|
|
299
|
+
const description = [
|
|
300
|
+
truncated ? t('analytics.topOf', { shown: rows.length, total }) : caption,
|
|
301
|
+
coverageFrom == null
|
|
302
|
+
? undefined
|
|
303
|
+
: t('analytics.coverage.since', {
|
|
304
|
+
date: coverageDate.format(new Date(`${coverageFrom}T00:00:00.000Z`)),
|
|
305
|
+
}),
|
|
306
|
+
]
|
|
307
|
+
.filter((value): value is string => value != null)
|
|
308
|
+
.join(' · ')
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<Card className={cx('byline-analytics-list', styles.list)}>
|
|
312
|
+
<Card.Header>
|
|
313
|
+
<Card.Title>{title}</Card.Title>
|
|
314
|
+
{description.length > 0 && <Card.Description>{description}</Card.Description>}
|
|
315
|
+
</Card.Header>
|
|
316
|
+
<Card.Content>
|
|
317
|
+
{rows.length === 0 ? (
|
|
318
|
+
<p className="muted">{t('analytics.empty')}</p>
|
|
319
|
+
) : (
|
|
320
|
+
<ol className={cx('byline-analytics-ranking', styles.ranking)}>
|
|
321
|
+
{rows.map((row) => (
|
|
322
|
+
<li
|
|
323
|
+
key={row.key}
|
|
324
|
+
className={cx(
|
|
325
|
+
'byline-analytics-ranking-row',
|
|
326
|
+
styles.rankingRow,
|
|
327
|
+
TONE_BAR[tone],
|
|
328
|
+
row.overflow && styles.rankingOverflow
|
|
329
|
+
)}
|
|
330
|
+
// The share bar sits behind the row so the label and its
|
|
331
|
+
// magnitude occupy one line and are read together.
|
|
332
|
+
style={
|
|
333
|
+
{
|
|
334
|
+
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`,
|
|
335
|
+
} as React.CSSProperties
|
|
336
|
+
}
|
|
337
|
+
>
|
|
338
|
+
<span className={styles.rankingLabel} title={row.key}>
|
|
339
|
+
{/* `__other__` is a reserved aggregate, not a page anyone
|
|
340
|
+
visited — never render it as though it were a real path. */}
|
|
341
|
+
{row.overflow ? t('analytics.overflow') : row.label}
|
|
342
|
+
</span>
|
|
343
|
+
<span className={styles.rankingValue}>{numbers.format(row.value)}</span>
|
|
344
|
+
<span className={styles.rankingVisitors}>{numbers.format(row.visitors)}</span>
|
|
345
|
+
</li>
|
|
346
|
+
))}
|
|
347
|
+
</ol>
|
|
348
|
+
)}
|
|
349
|
+
</Card.Content>
|
|
350
|
+
</Card>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
|
|
355
|
+
export function shareWidth(value: number, ceiling: number): number {
|
|
356
|
+
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0
|
|
357
|
+
return Math.max(3, Math.min(100, (value / ceiling) * 100))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function formatShare(part: number, whole: number, locale: string): string {
|
|
361
|
+
const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 })
|
|
362
|
+
return percent.format(whole <= 0 ? 0 : part / whole)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Return the retained boundary only when it truncates the selected range. */
|
|
366
|
+
export function partialCoverageFrom(
|
|
367
|
+
rangeFrom: string,
|
|
368
|
+
coverageFrom: string | null
|
|
369
|
+
): string | undefined {
|
|
370
|
+
return coverageFrom != null && coverageFrom > rangeFrom ? coverageFrom : undefined
|
|
371
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsTimeseries — one column per UTC day.
|
|
3
|
+
*
|
|
4
|
+
* Override handles:
|
|
5
|
+
* .byline-analytics-timeseries — the chart, axis, and readout as one block
|
|
6
|
+
* .byline-analytics-chart — the SVG plot itself
|
|
7
|
+
* .byline-analytics-axis — first/last day labels beneath the plot
|
|
8
|
+
* .byline-analytics-readout — hovered-day figures, or the range hint
|
|
9
|
+
*
|
|
10
|
+
* The metric hues match the collection dashboard's status tiles
|
|
11
|
+
* (`byline-dashboard-stat-tile`) so the two surfaces read as one system.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
.root {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 0.5rem;
|
|
18
|
+
border-radius: 0.25rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.root:focus-visible {
|
|
22
|
+
outline: 2px solid var(--primary-500);
|
|
23
|
+
outline-offset: 4px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.chart,
|
|
27
|
+
:global(.byline-analytics-chart) {
|
|
28
|
+
display: block;
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 11rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.gridline {
|
|
34
|
+
stroke: var(--gray-200);
|
|
35
|
+
stroke-width: 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.column {
|
|
39
|
+
transition: opacity 120ms ease;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.views {
|
|
43
|
+
fill: #38bdf8;
|
|
44
|
+
opacity: 0.38;
|
|
45
|
+
transition: opacity 120ms ease;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.columnActive .views {
|
|
49
|
+
opacity: 0.62;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.visitors {
|
|
53
|
+
fill: #7c3aed;
|
|
54
|
+
opacity: 0.95;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.hit {
|
|
58
|
+
fill: transparent;
|
|
59
|
+
cursor: crosshair;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.axis,
|
|
63
|
+
:global(.byline-analytics-axis) {
|
|
64
|
+
display: flex;
|
|
65
|
+
justify-content: space-between;
|
|
66
|
+
font-size: 0.6875rem;
|
|
67
|
+
font-variant-numeric: tabular-nums;
|
|
68
|
+
letter-spacing: 0.04em;
|
|
69
|
+
color: var(--gray-500);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.readout,
|
|
73
|
+
:global(.byline-analytics-readout) {
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
gap: 0.35rem 0.75rem;
|
|
78
|
+
margin: 0;
|
|
79
|
+
min-height: 1.25rem;
|
|
80
|
+
font-size: 0.75rem;
|
|
81
|
+
font-variant-numeric: tabular-nums;
|
|
82
|
+
color: var(--gray-500);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.readout strong {
|
|
86
|
+
color: inherit;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.swatchViews,
|
|
91
|
+
.swatchVisitors,
|
|
92
|
+
.swatchDownloads {
|
|
93
|
+
display: inline-block;
|
|
94
|
+
width: 0.5rem;
|
|
95
|
+
height: 0.5rem;
|
|
96
|
+
border-radius: 1px;
|
|
97
|
+
/* Each swatch introduces the pair that follows it, so it needs the gap on
|
|
98
|
+
its right closed up without collapsing the row gap. */
|
|
99
|
+
margin-inline-start: 0.4rem;
|
|
100
|
+
margin-inline-end: -0.4rem;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.swatchViews {
|
|
104
|
+
background-color: #38bdf8;
|
|
105
|
+
opacity: 0.55;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.swatchVisitors {
|
|
109
|
+
background-color: #7c3aed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.swatchDownloads {
|
|
113
|
+
background-color: #10b981;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
:is([data-theme="dark"], :global(.dark)) .gridline {
|
|
117
|
+
stroke: var(--gray-700);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
:is([data-theme="dark"], :global(.dark)) .axis,
|
|
121
|
+
:is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-axis),
|
|
122
|
+
:is([data-theme="dark"], :global(.dark)) .readout,
|
|
123
|
+
:is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-readout) {
|
|
124
|
+
color: var(--gray-400);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
:is([data-theme="dark"], :global(.dark)) .visitors {
|
|
128
|
+
fill: #a78bfa;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
:is([data-theme="dark"], :global(.dark)) .swatchVisitors {
|
|
132
|
+
background-color: #a78bfa;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
:is([data-theme="dark"], :global(.dark)) .swatchDownloads {
|
|
136
|
+
background-color: #34d399;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@media (prefers-reduced-motion: reduce) {
|
|
140
|
+
.column,
|
|
141
|
+
.views {
|
|
142
|
+
transition: none;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@media (max-width: 720px) {
|
|
147
|
+
.chart,
|
|
148
|
+
:global(.byline-analytics-chart) {
|
|
149
|
+
height: 9rem;
|
|
150
|
+
}
|
|
151
|
+
}
|