@better-forge/ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/lib/changelog.ts
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
export interface ReleaseItem {
|
|
2
|
-
title: string
|
|
3
|
-
summary: string
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface ReleaseGroup {
|
|
7
|
-
heading: string
|
|
8
|
-
items: ReleaseItem[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface ReleaseNotes {
|
|
12
|
-
version: string
|
|
13
|
-
date: string
|
|
14
|
-
groups: ReleaseGroup[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const RELEASE_HEADING = /^## \[(\d+\.\d+\.\d+)\](?: - (\S+))?\s*$/
|
|
18
|
-
const GROUP_HEADING = /^### (.+?)\s*$/
|
|
19
|
-
const ITEM = /^- (?:\*\*(.+?)\*\*:?\s*)?(.*)$/
|
|
20
|
-
|
|
21
|
-
function plain(text: string): string {
|
|
22
|
-
return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*]/g, '').trim()
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function firstSentence(text: string): string {
|
|
26
|
-
const match = /^(.+?[.!?])(?:\s|$)/.exec(text)
|
|
27
|
-
return plain(match ? match[1]! : text)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Released sections of docs/changelog.md, newest first; each top-level bullet is cut down to its bold title and
|
|
32
|
-
* the first sentence of its text. The Unreleased section and nested bullets are left out.
|
|
33
|
-
*/
|
|
34
|
-
export function parseChangelog(markdown: string): ReleaseNotes[] {
|
|
35
|
-
const releases: ReleaseNotes[] = []
|
|
36
|
-
let release: ReleaseNotes | null = null
|
|
37
|
-
let group: ReleaseGroup | null = null
|
|
38
|
-
for (const line of markdown.split(/\r?\n/)) {
|
|
39
|
-
const releaseMatch = RELEASE_HEADING.exec(line)
|
|
40
|
-
if (releaseMatch) {
|
|
41
|
-
release = { version: releaseMatch[1]!, date: releaseMatch[2] ?? '', groups: [] }
|
|
42
|
-
releases.push(release)
|
|
43
|
-
group = null
|
|
44
|
-
} else if (line.startsWith('## ')) {
|
|
45
|
-
release = null
|
|
46
|
-
} else if (release) {
|
|
47
|
-
const groupMatch = GROUP_HEADING.exec(line)
|
|
48
|
-
if (groupMatch) {
|
|
49
|
-
group = { heading: groupMatch[1]!, items: [] }
|
|
50
|
-
release.groups.push(group)
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
53
|
-
const item = group ? ITEM.exec(line) : null
|
|
54
|
-
if (item && group) group.items.push({ title: plain(item[1] ?? ''), summary: firstSentence(item[2] ?? '') })
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return releases.map((r) => ({ ...r, groups: r.groups.filter((g) => g.items.length > 0) }))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Negative, zero or positive like a sort comparator; plain x.y.z versions only. */
|
|
61
|
-
export function compareVersions(a: string, b: string): number {
|
|
62
|
-
const left = a.split('.').map(Number)
|
|
63
|
-
const right = b.split('.').map(Number)
|
|
64
|
-
for (let i = 0; i < 3; i++) {
|
|
65
|
-
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
66
|
-
if (diff !== 0) return diff
|
|
67
|
-
}
|
|
68
|
-
return 0
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Releases after `seen` up to and including `current`, newest first. */
|
|
72
|
-
export function releasesBetween(releases: ReleaseNotes[], seen: string, current: string): ReleaseNotes[] {
|
|
73
|
-
return releases.filter((r) => compareVersions(r.version, seen) > 0 && compareVersions(r.version, current) <= 0)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface ReleaseSummary {
|
|
77
|
-
version: string
|
|
78
|
-
added: ReleaseItem[]
|
|
79
|
-
/** Changed, Removed and any other kind of entry that is not new or a fix. */
|
|
80
|
-
changed: ReleaseItem[]
|
|
81
|
-
fixed: ReleaseItem[]
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** One release sorted by the kind of change; fixes are only counted. */
|
|
85
|
-
export function summarizeRelease(release: ReleaseNotes): ReleaseSummary {
|
|
86
|
-
const summary: ReleaseSummary = { version: release.version, added: [], changed: [], fixed: [] }
|
|
87
|
-
for (const group of release.groups) {
|
|
88
|
-
if (group.heading === 'Added') summary.added.push(...group.items)
|
|
89
|
-
else if (group.heading === 'Fixed') summary.fixed.push(...group.items)
|
|
90
|
-
else summary.changed.push(...group.items)
|
|
91
|
-
}
|
|
92
|
-
return summary
|
|
93
|
-
}
|
|
1
|
+
export interface ReleaseItem {
|
|
2
|
+
title: string
|
|
3
|
+
summary: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ReleaseGroup {
|
|
7
|
+
heading: string
|
|
8
|
+
items: ReleaseItem[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReleaseNotes {
|
|
12
|
+
version: string
|
|
13
|
+
date: string
|
|
14
|
+
groups: ReleaseGroup[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RELEASE_HEADING = /^## \[(\d+\.\d+\.\d+)\](?: - (\S+))?\s*$/
|
|
18
|
+
const GROUP_HEADING = /^### (.+?)\s*$/
|
|
19
|
+
const ITEM = /^- (?:\*\*(.+?)\*\*:?\s*)?(.*)$/
|
|
20
|
+
|
|
21
|
+
function plain(text: string): string {
|
|
22
|
+
return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*]/g, '').trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function firstSentence(text: string): string {
|
|
26
|
+
const match = /^(.+?[.!?])(?:\s|$)/.exec(text)
|
|
27
|
+
return plain(match ? match[1]! : text)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Released sections of docs/changelog.md, newest first; each top-level bullet is cut down to its bold title and
|
|
32
|
+
* the first sentence of its text. The Unreleased section and nested bullets are left out.
|
|
33
|
+
*/
|
|
34
|
+
export function parseChangelog(markdown: string): ReleaseNotes[] {
|
|
35
|
+
const releases: ReleaseNotes[] = []
|
|
36
|
+
let release: ReleaseNotes | null = null
|
|
37
|
+
let group: ReleaseGroup | null = null
|
|
38
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
39
|
+
const releaseMatch = RELEASE_HEADING.exec(line)
|
|
40
|
+
if (releaseMatch) {
|
|
41
|
+
release = { version: releaseMatch[1]!, date: releaseMatch[2] ?? '', groups: [] }
|
|
42
|
+
releases.push(release)
|
|
43
|
+
group = null
|
|
44
|
+
} else if (line.startsWith('## ')) {
|
|
45
|
+
release = null
|
|
46
|
+
} else if (release) {
|
|
47
|
+
const groupMatch = GROUP_HEADING.exec(line)
|
|
48
|
+
if (groupMatch) {
|
|
49
|
+
group = { heading: groupMatch[1]!, items: [] }
|
|
50
|
+
release.groups.push(group)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const item = group ? ITEM.exec(line) : null
|
|
54
|
+
if (item && group) group.items.push({ title: plain(item[1] ?? ''), summary: firstSentence(item[2] ?? '') })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return releases.map((r) => ({ ...r, groups: r.groups.filter((g) => g.items.length > 0) }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Negative, zero or positive like a sort comparator; plain x.y.z versions only. */
|
|
61
|
+
export function compareVersions(a: string, b: string): number {
|
|
62
|
+
const left = a.split('.').map(Number)
|
|
63
|
+
const right = b.split('.').map(Number)
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
66
|
+
if (diff !== 0) return diff
|
|
67
|
+
}
|
|
68
|
+
return 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Releases after `seen` up to and including `current`, newest first. */
|
|
72
|
+
export function releasesBetween(releases: ReleaseNotes[], seen: string, current: string): ReleaseNotes[] {
|
|
73
|
+
return releases.filter((r) => compareVersions(r.version, seen) > 0 && compareVersions(r.version, current) <= 0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReleaseSummary {
|
|
77
|
+
version: string
|
|
78
|
+
added: ReleaseItem[]
|
|
79
|
+
/** Changed, Removed and any other kind of entry that is not new or a fix. */
|
|
80
|
+
changed: ReleaseItem[]
|
|
81
|
+
fixed: ReleaseItem[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One release sorted by the kind of change; fixes are only counted. */
|
|
85
|
+
export function summarizeRelease(release: ReleaseNotes): ReleaseSummary {
|
|
86
|
+
const summary: ReleaseSummary = { version: release.version, added: [], changed: [], fixed: [] }
|
|
87
|
+
for (const group of release.groups) {
|
|
88
|
+
if (group.heading === 'Added') summary.added.push(...group.items)
|
|
89
|
+
else if (group.heading === 'Fixed') summary.fixed.push(...group.items)
|
|
90
|
+
else summary.changed.push(...group.items)
|
|
91
|
+
}
|
|
92
|
+
return summary
|
|
93
|
+
}
|
package/src/lib/dateFormat.ts
CHANGED
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import { safeLocalStorage } from '@better-forge/contracts'
|
|
2
|
-
import { useSyncExternalStore } from 'react'
|
|
3
|
-
import { storageKey } from '../config'
|
|
4
|
-
|
|
5
|
-
/** Admin date and time display, chosen per browser in Settings. */
|
|
6
|
-
export type DateFormat = 'dmy' | 'dmyDash' | 'mdy' | 'ymd' | 'ymdDot'
|
|
7
|
-
export type TimeFormat = '24h' | '12h'
|
|
8
|
-
export interface DatePrefs { date: DateFormat; time: TimeFormat }
|
|
9
|
-
|
|
10
|
-
interface DatePattern { label: string; full: (y: number, m: string, d: string) => string; dayMonth: (m: string, d: string) => string }
|
|
11
|
-
|
|
12
|
-
const DATE_PATTERNS: Record<DateFormat, DatePattern> = {
|
|
13
|
-
dmy: { label: 'dd.mm.yyyy', full: (y, m, d) => `${d}.${m}.${y}`, dayMonth: (m, d) => `${d}.${m}` },
|
|
14
|
-
dmyDash: { label: 'dd-mm-yyyy', full: (y, m, d) => `${d}-${m}-${y}`, dayMonth: (m, d) => `${d}-${m}` },
|
|
15
|
-
mdy: { label: 'mm/dd/yyyy', full: (y, m, d) => `${m}/${d}/${y}`, dayMonth: (m, d) => `${m}/${d}` },
|
|
16
|
-
ymd: { label: 'yyyy-mm-dd', full: (y, m, d) => `${y}-${m}-${d}`, dayMonth: (m, d) => `${m}-${d}` },
|
|
17
|
-
ymdDot: { label: 'yyyy.mm.dd', full: (y, m, d) => `${y}.${m}.${d}`, dayMonth: (m, d) => `${m}.${d}` },
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const DATE_FORMATS = (Object.keys(DATE_PATTERNS) as DateFormat[]).map((value) => ({ value, label: DATE_PATTERNS[value].label }))
|
|
21
|
-
export const TIME_FORMATS: ReadonlyArray<{ value: TimeFormat; label: string }> = [
|
|
22
|
-
{ value: '24h', label: '24-hour' },
|
|
23
|
-
{ value: '12h', label: '12-hour' },
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
const DEFAULTS: DatePrefs = { date: 'dmy', time: '24h' }
|
|
27
|
-
|
|
28
|
-
function load(): DatePrefs {
|
|
29
|
-
try {
|
|
30
|
-
const raw = JSON.parse(safeLocalStorage.get(storageKey('date-format')) ?? 'null') as Partial<DatePrefs> | null
|
|
31
|
-
return {
|
|
32
|
-
date: DATE_FORMATS.some((f) => f.value === raw?.date) ? raw!.date! : DEFAULTS.date,
|
|
33
|
-
time: TIME_FORMATS.some((f) => f.value === raw?.time) ? raw!.time! : DEFAULTS.time,
|
|
34
|
-
}
|
|
35
|
-
} catch {
|
|
36
|
-
return DEFAULTS
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
let prefs: DatePrefs | null = null
|
|
41
|
-
const listeners = new Set<() => void>()
|
|
42
|
-
|
|
43
|
-
export function getDatePrefs(): DatePrefs { return (prefs ??= load()) }
|
|
44
|
-
|
|
45
|
-
export function setDatePrefs(next: DatePrefs): void {
|
|
46
|
-
prefs = next
|
|
47
|
-
safeLocalStorage.set(storageKey('date-format'), JSON.stringify(next))
|
|
48
|
-
listeners.forEach((listener) => listener())
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function subscribe(listener: () => void): () => void {
|
|
52
|
-
listeners.add(listener)
|
|
53
|
-
return () => { listeners.delete(listener) }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function useDatePrefs(): DatePrefs {
|
|
57
|
-
return useSyncExternalStore(subscribe, getDatePrefs)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
61
|
-
|
|
62
|
-
/** `utc` formats the UTC calendar day instead of the local one (for UTC-bucketed data). */
|
|
63
|
-
export function formatDate(ms: number, utc = false): string {
|
|
64
|
-
const d = new Date(ms)
|
|
65
|
-
return DATE_PATTERNS[getDatePrefs().date].full(
|
|
66
|
-
utc ? d.getUTCFullYear() : d.getFullYear(),
|
|
67
|
-
pad((utc ? d.getUTCMonth() : d.getMonth()) + 1),
|
|
68
|
-
pad(utc ? d.getUTCDate() : d.getDate()),
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function formatDayMonth(ms: number): string {
|
|
73
|
-
const d = new Date(ms)
|
|
74
|
-
return DATE_PATTERNS[getDatePrefs().date].dayMonth(pad(d.getMonth() + 1), pad(d.getDate()))
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function formatClock(ms: number, seconds = false): string {
|
|
78
|
-
const d = new Date(ms)
|
|
79
|
-
const mm = pad(d.getMinutes())
|
|
80
|
-
const ss = seconds ? `:${pad(d.getSeconds())}` : ''
|
|
81
|
-
if (getDatePrefs().time === '12h') {
|
|
82
|
-
const h = d.getHours()
|
|
83
|
-
return `${h % 12 || 12}:${mm}${ss} ${h < 12 ? 'AM' : 'PM'}`
|
|
84
|
-
}
|
|
85
|
-
return `${pad(d.getHours())}:${mm}${ss}`
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function formatDateTime(ms: number, seconds = false): string {
|
|
89
|
-
return `${formatDate(ms)} ${formatClock(ms, seconds)}`
|
|
90
|
-
}
|
|
1
|
+
import { safeLocalStorage } from '@better-forge/contracts'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
import { storageKey } from '../config'
|
|
4
|
+
|
|
5
|
+
/** Admin date and time display, chosen per browser in Settings. */
|
|
6
|
+
export type DateFormat = 'dmy' | 'dmyDash' | 'mdy' | 'ymd' | 'ymdDot'
|
|
7
|
+
export type TimeFormat = '24h' | '12h'
|
|
8
|
+
export interface DatePrefs { date: DateFormat; time: TimeFormat }
|
|
9
|
+
|
|
10
|
+
interface DatePattern { label: string; full: (y: number, m: string, d: string) => string; dayMonth: (m: string, d: string) => string }
|
|
11
|
+
|
|
12
|
+
const DATE_PATTERNS: Record<DateFormat, DatePattern> = {
|
|
13
|
+
dmy: { label: 'dd.mm.yyyy', full: (y, m, d) => `${d}.${m}.${y}`, dayMonth: (m, d) => `${d}.${m}` },
|
|
14
|
+
dmyDash: { label: 'dd-mm-yyyy', full: (y, m, d) => `${d}-${m}-${y}`, dayMonth: (m, d) => `${d}-${m}` },
|
|
15
|
+
mdy: { label: 'mm/dd/yyyy', full: (y, m, d) => `${m}/${d}/${y}`, dayMonth: (m, d) => `${m}/${d}` },
|
|
16
|
+
ymd: { label: 'yyyy-mm-dd', full: (y, m, d) => `${y}-${m}-${d}`, dayMonth: (m, d) => `${m}-${d}` },
|
|
17
|
+
ymdDot: { label: 'yyyy.mm.dd', full: (y, m, d) => `${y}.${m}.${d}`, dayMonth: (m, d) => `${m}.${d}` },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DATE_FORMATS = (Object.keys(DATE_PATTERNS) as DateFormat[]).map((value) => ({ value, label: DATE_PATTERNS[value].label }))
|
|
21
|
+
export const TIME_FORMATS: ReadonlyArray<{ value: TimeFormat; label: string }> = [
|
|
22
|
+
{ value: '24h', label: '24-hour' },
|
|
23
|
+
{ value: '12h', label: '12-hour' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const DEFAULTS: DatePrefs = { date: 'dmy', time: '24h' }
|
|
27
|
+
|
|
28
|
+
function load(): DatePrefs {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(safeLocalStorage.get(storageKey('date-format')) ?? 'null') as Partial<DatePrefs> | null
|
|
31
|
+
return {
|
|
32
|
+
date: DATE_FORMATS.some((f) => f.value === raw?.date) ? raw!.date! : DEFAULTS.date,
|
|
33
|
+
time: TIME_FORMATS.some((f) => f.value === raw?.time) ? raw!.time! : DEFAULTS.time,
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return DEFAULTS
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let prefs: DatePrefs | null = null
|
|
41
|
+
const listeners = new Set<() => void>()
|
|
42
|
+
|
|
43
|
+
export function getDatePrefs(): DatePrefs { return (prefs ??= load()) }
|
|
44
|
+
|
|
45
|
+
export function setDatePrefs(next: DatePrefs): void {
|
|
46
|
+
prefs = next
|
|
47
|
+
safeLocalStorage.set(storageKey('date-format'), JSON.stringify(next))
|
|
48
|
+
listeners.forEach((listener) => listener())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function subscribe(listener: () => void): () => void {
|
|
52
|
+
listeners.add(listener)
|
|
53
|
+
return () => { listeners.delete(listener) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useDatePrefs(): DatePrefs {
|
|
57
|
+
return useSyncExternalStore(subscribe, getDatePrefs)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
61
|
+
|
|
62
|
+
/** `utc` formats the UTC calendar day instead of the local one (for UTC-bucketed data). */
|
|
63
|
+
export function formatDate(ms: number, utc = false): string {
|
|
64
|
+
const d = new Date(ms)
|
|
65
|
+
return DATE_PATTERNS[getDatePrefs().date].full(
|
|
66
|
+
utc ? d.getUTCFullYear() : d.getFullYear(),
|
|
67
|
+
pad((utc ? d.getUTCMonth() : d.getMonth()) + 1),
|
|
68
|
+
pad(utc ? d.getUTCDate() : d.getDate()),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatDayMonth(ms: number): string {
|
|
73
|
+
const d = new Date(ms)
|
|
74
|
+
return DATE_PATTERNS[getDatePrefs().date].dayMonth(pad(d.getMonth() + 1), pad(d.getDate()))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatClock(ms: number, seconds = false): string {
|
|
78
|
+
const d = new Date(ms)
|
|
79
|
+
const mm = pad(d.getMinutes())
|
|
80
|
+
const ss = seconds ? `:${pad(d.getSeconds())}` : ''
|
|
81
|
+
if (getDatePrefs().time === '12h') {
|
|
82
|
+
const h = d.getHours()
|
|
83
|
+
return `${h % 12 || 12}:${mm}${ss} ${h < 12 ? 'AM' : 'PM'}`
|
|
84
|
+
}
|
|
85
|
+
return `${pad(d.getHours())}:${mm}${ss}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatDateTime(ms: number, seconds = false): string {
|
|
89
|
+
return `${formatDate(ms)} ${formatClock(ms, seconds)}`
|
|
90
|
+
}
|
package/src/navigation.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** Full-page navigation, kept in one object so tests can observe it (jsdom cannot navigate). */
|
|
2
|
-
export const navigation = {
|
|
3
|
-
assign(url: string): void {
|
|
4
|
-
window.location.assign(url)
|
|
5
|
-
},
|
|
6
|
-
}
|
|
1
|
+
/** Full-page navigation, kept in one object so tests can observe it (jsdom cannot navigate). */
|
|
2
|
+
export const navigation = {
|
|
3
|
+
assign(url: string): void {
|
|
4
|
+
window.location.assign(url)
|
|
5
|
+
},
|
|
6
|
+
}
|