@growth-labs/cms 0.8.19 → 0.8.20
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/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +2 -102
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/publish-timezone.ts +154 -0
- package/src/routes/content.ts +5 -116
- package/src/ui/inspector/PublishTab.tsx +33 -25
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publication-timezone helpers — ONE implementation, shared by the schedule
|
|
3
|
+
* route and the Publish tab.
|
|
4
|
+
*
|
|
5
|
+
* Why this module exists: the editor types a wall-clock time and expects the
|
|
6
|
+
* post to go out at that time in the publication's timezone. That only holds
|
|
7
|
+
* if the digits the UI DISPLAYS and the zone it SAVES agree. When they
|
|
8
|
+
* disagreed, every resave shifted `publish_at` by the zone offset — including
|
|
9
|
+
* an incidental resave that never touched the field (the 2026-09-08 P0). So
|
|
10
|
+
* display and entry must use the same converter as the server, not a second
|
|
11
|
+
* copy of the arithmetic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Fronts default; the server falls back to this when no zone is configured. */
|
|
15
|
+
export const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
|
|
16
|
+
|
|
17
|
+
/** `YYYY-MM-DDTHH:MM` exactly — the shape the date and time inputs produce. */
|
|
18
|
+
const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
|
19
|
+
|
|
20
|
+
type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
|
|
21
|
+
|
|
22
|
+
export function parseLocalDateTime(value: string): LocalDateTimeParts | null {
|
|
23
|
+
const match = LOCAL_DATE_TIME_RE.exec(value)
|
|
24
|
+
if (!match) return null
|
|
25
|
+
const [, y, m, d, h, min] = match
|
|
26
|
+
const parts: LocalDateTimeParts = {
|
|
27
|
+
year: Number.parseInt(y, 10),
|
|
28
|
+
month: Number.parseInt(m, 10),
|
|
29
|
+
day: Number.parseInt(d, 10),
|
|
30
|
+
hour: Number.parseInt(h, 10),
|
|
31
|
+
minute: Number.parseInt(min, 10),
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
!Number.isInteger(parts.year) ||
|
|
35
|
+
parts.month < 1 ||
|
|
36
|
+
parts.month > 12 ||
|
|
37
|
+
parts.day < 1 ||
|
|
38
|
+
parts.day > 31 ||
|
|
39
|
+
parts.hour < 0 ||
|
|
40
|
+
parts.hour > 23 ||
|
|
41
|
+
parts.minute < 0 ||
|
|
42
|
+
parts.minute > 59
|
|
43
|
+
) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
|
47
|
+
if (
|
|
48
|
+
dateCheck.getUTCFullYear() !== parts.year ||
|
|
49
|
+
dateCheck.getUTCMonth() + 1 !== parts.month ||
|
|
50
|
+
dateCheck.getUTCDate() !== parts.day
|
|
51
|
+
) {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
return parts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
|
|
58
|
+
if (!isValidTimeZone(timeZone)) return null
|
|
59
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
60
|
+
timeZone,
|
|
61
|
+
hour12: false,
|
|
62
|
+
year: 'numeric',
|
|
63
|
+
month: '2-digit',
|
|
64
|
+
day: '2-digit',
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
second: '2-digit',
|
|
68
|
+
})
|
|
69
|
+
const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
|
|
70
|
+
for (const part of formatter.formatToParts(new Date(instantMs))) {
|
|
71
|
+
if (part.type in values) values[part.type as keyof typeof values] = part.value
|
|
72
|
+
}
|
|
73
|
+
const year = Number.parseInt(values.year, 10)
|
|
74
|
+
const month = Number.parseInt(values.month, 10)
|
|
75
|
+
const day = Number.parseInt(values.day, 10)
|
|
76
|
+
const hour = Number.parseInt(values.hour, 10)
|
|
77
|
+
const minute = Number.parseInt(values.minute, 10)
|
|
78
|
+
if (![year, month, day, hour, minute].every(Number.isFinite)) return null
|
|
79
|
+
return { year, month, day, hour, minute }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isValidTimeZone(timeZone: string): boolean {
|
|
83
|
+
try {
|
|
84
|
+
new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
|
|
85
|
+
return true
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
|
|
92
|
+
const zoned = getZonedParts(instantMs, timeZone)
|
|
93
|
+
if (!zoned) return null
|
|
94
|
+
const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
|
|
95
|
+
return asUtc - instantMs
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parseLocalDateTimeInZoneToUnixSeconds(
|
|
99
|
+
value: string,
|
|
100
|
+
timeZone: string,
|
|
101
|
+
): number | null {
|
|
102
|
+
const target = parseLocalDateTime(value)
|
|
103
|
+
if (!target) return null
|
|
104
|
+
const targetUtcMs = Date.UTC(
|
|
105
|
+
target.year,
|
|
106
|
+
target.month - 1,
|
|
107
|
+
target.day,
|
|
108
|
+
target.hour,
|
|
109
|
+
target.minute,
|
|
110
|
+
0,
|
|
111
|
+
)
|
|
112
|
+
let candidateMs = targetUtcMs
|
|
113
|
+
for (let i = 0; i < 5; i += 1) {
|
|
114
|
+
const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
|
|
115
|
+
if (offsetMs === null) return null
|
|
116
|
+
const next = targetUtcMs - offsetMs
|
|
117
|
+
if (Math.abs(next - candidateMs) < 1000) {
|
|
118
|
+
candidateMs = next
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
candidateMs = next
|
|
122
|
+
}
|
|
123
|
+
const resolved = getZonedParts(candidateMs, timeZone)
|
|
124
|
+
if (
|
|
125
|
+
!resolved ||
|
|
126
|
+
resolved.year !== target.year ||
|
|
127
|
+
resolved.month !== target.month ||
|
|
128
|
+
resolved.day !== target.day ||
|
|
129
|
+
resolved.hour !== target.hour ||
|
|
130
|
+
resolved.minute !== target.minute
|
|
131
|
+
) {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
return Math.floor(candidateMs / 1000)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Inverse of `parseLocalDateTimeInZoneToUnixSeconds`: render an instant as the
|
|
139
|
+
* wall-clock digits an editor in `timeZone` would read, split for the date and
|
|
140
|
+
* time inputs. Returns null when the zone is unusable, so callers can fall back
|
|
141
|
+
* rather than silently display a wrong hour.
|
|
142
|
+
*/
|
|
143
|
+
export function formatInstantInZone(
|
|
144
|
+
instantMs: number,
|
|
145
|
+
timeZone: string,
|
|
146
|
+
): { date: string; time: string } | null {
|
|
147
|
+
const parts = getZonedParts(instantMs, timeZone)
|
|
148
|
+
if (!parts) return null
|
|
149
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, '0')
|
|
150
|
+
return {
|
|
151
|
+
date: `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)}`,
|
|
152
|
+
time: `${pad(parts.hour)}:${pad(parts.minute)}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/routes/content.ts
CHANGED
|
@@ -66,6 +66,11 @@ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/s
|
|
|
66
66
|
import { normalizeSourceSections } from '../engine/source-sections.js'
|
|
67
67
|
import { TagInputError } from '../engine/taxonomy.js'
|
|
68
68
|
import type { ValidationError } from '../engine/validator/index.js'
|
|
69
|
+
import {
|
|
70
|
+
isValidTimeZone,
|
|
71
|
+
PUBLISH_TIMEZONE_FALLBACK,
|
|
72
|
+
parseLocalDateTimeInZoneToUnixSeconds,
|
|
73
|
+
} from '../lib/publish-timezone.js'
|
|
69
74
|
import type { MediaSourceResolution } from '../providers/types.js'
|
|
70
75
|
import { parseLayoutEnvelope } from '../schema/layout.js'
|
|
71
76
|
import { parsePortableTextEnvelope } from '../schema/portable-text.js'
|
|
@@ -242,9 +247,6 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
242
247
|
activeWorkspaceId?: string
|
|
243
248
|
}
|
|
244
249
|
|
|
245
|
-
const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
|
|
246
|
-
const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
|
247
|
-
|
|
248
250
|
type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
|
|
249
251
|
|
|
250
252
|
/**
|
|
@@ -677,119 +679,6 @@ const ContributorsReplaceSchema = z.object({
|
|
|
677
679
|
|
|
678
680
|
// --- timezone-aware local-datetime parsing (ported verbatim) --------------
|
|
679
681
|
|
|
680
|
-
type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
|
|
681
|
-
|
|
682
|
-
function parseLocalDateTime(value: string): LocalDateTimeParts | null {
|
|
683
|
-
const match = LOCAL_DATE_TIME_RE.exec(value)
|
|
684
|
-
if (!match) return null
|
|
685
|
-
const [, y, m, d, h, min] = match
|
|
686
|
-
const parts: LocalDateTimeParts = {
|
|
687
|
-
year: Number.parseInt(y, 10),
|
|
688
|
-
month: Number.parseInt(m, 10),
|
|
689
|
-
day: Number.parseInt(d, 10),
|
|
690
|
-
hour: Number.parseInt(h, 10),
|
|
691
|
-
minute: Number.parseInt(min, 10),
|
|
692
|
-
}
|
|
693
|
-
if (
|
|
694
|
-
!Number.isInteger(parts.year) ||
|
|
695
|
-
parts.month < 1 ||
|
|
696
|
-
parts.month > 12 ||
|
|
697
|
-
parts.day < 1 ||
|
|
698
|
-
parts.day > 31 ||
|
|
699
|
-
parts.hour < 0 ||
|
|
700
|
-
parts.hour > 23 ||
|
|
701
|
-
parts.minute < 0 ||
|
|
702
|
-
parts.minute > 59
|
|
703
|
-
) {
|
|
704
|
-
return null
|
|
705
|
-
}
|
|
706
|
-
const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
|
707
|
-
if (
|
|
708
|
-
dateCheck.getUTCFullYear() !== parts.year ||
|
|
709
|
-
dateCheck.getUTCMonth() + 1 !== parts.month ||
|
|
710
|
-
dateCheck.getUTCDate() !== parts.day
|
|
711
|
-
) {
|
|
712
|
-
return null
|
|
713
|
-
}
|
|
714
|
-
return parts
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
|
|
718
|
-
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
719
|
-
timeZone,
|
|
720
|
-
hour12: false,
|
|
721
|
-
year: 'numeric',
|
|
722
|
-
month: '2-digit',
|
|
723
|
-
day: '2-digit',
|
|
724
|
-
hour: '2-digit',
|
|
725
|
-
minute: '2-digit',
|
|
726
|
-
second: '2-digit',
|
|
727
|
-
})
|
|
728
|
-
const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
|
|
729
|
-
for (const part of formatter.formatToParts(new Date(instantMs))) {
|
|
730
|
-
if (part.type in values) values[part.type as keyof typeof values] = part.value
|
|
731
|
-
}
|
|
732
|
-
const year = Number.parseInt(values.year, 10)
|
|
733
|
-
const month = Number.parseInt(values.month, 10)
|
|
734
|
-
const day = Number.parseInt(values.day, 10)
|
|
735
|
-
const hour = Number.parseInt(values.hour, 10)
|
|
736
|
-
const minute = Number.parseInt(values.minute, 10)
|
|
737
|
-
if (![year, month, day, hour, minute].every(Number.isFinite)) return null
|
|
738
|
-
return { year, month, day, hour, minute }
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function isValidTimeZone(timeZone: string): boolean {
|
|
742
|
-
try {
|
|
743
|
-
new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
|
|
744
|
-
return true
|
|
745
|
-
} catch {
|
|
746
|
-
return false
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
|
|
751
|
-
const zoned = getZonedParts(instantMs, timeZone)
|
|
752
|
-
if (!zoned) return null
|
|
753
|
-
const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
|
|
754
|
-
return asUtc - instantMs
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string): number | null {
|
|
758
|
-
const target = parseLocalDateTime(value)
|
|
759
|
-
if (!target) return null
|
|
760
|
-
const targetUtcMs = Date.UTC(
|
|
761
|
-
target.year,
|
|
762
|
-
target.month - 1,
|
|
763
|
-
target.day,
|
|
764
|
-
target.hour,
|
|
765
|
-
target.minute,
|
|
766
|
-
0,
|
|
767
|
-
)
|
|
768
|
-
let candidateMs = targetUtcMs
|
|
769
|
-
for (let i = 0; i < 5; i += 1) {
|
|
770
|
-
const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
|
|
771
|
-
if (offsetMs === null) return null
|
|
772
|
-
const next = targetUtcMs - offsetMs
|
|
773
|
-
if (Math.abs(next - candidateMs) < 1000) {
|
|
774
|
-
candidateMs = next
|
|
775
|
-
break
|
|
776
|
-
}
|
|
777
|
-
candidateMs = next
|
|
778
|
-
}
|
|
779
|
-
const resolved = getZonedParts(candidateMs, timeZone)
|
|
780
|
-
if (
|
|
781
|
-
!resolved ||
|
|
782
|
-
resolved.year !== target.year ||
|
|
783
|
-
resolved.month !== target.month ||
|
|
784
|
-
resolved.day !== target.day ||
|
|
785
|
-
resolved.hour !== target.hour ||
|
|
786
|
-
resolved.minute !== target.minute
|
|
787
|
-
) {
|
|
788
|
-
return null
|
|
789
|
-
}
|
|
790
|
-
return Math.floor(candidateMs / 1000)
|
|
791
|
-
}
|
|
792
|
-
|
|
793
682
|
// --- read-side payload helpers (ported verbatim) --------------------------
|
|
794
683
|
|
|
795
684
|
async function getContentPayload(ctx: RouteContext, type: string, id: string) {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// Compile-gated (tsc, jsx: react-jsx). No vitest — pure shapers are in
|
|
11
11
|
// inspector-data.ts and tested there.
|
|
12
12
|
|
|
13
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
13
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
14
|
+
import { formatInstantInZone, PUBLISH_TIMEZONE_FALLBACK } from '../../lib/publish-timezone.js'
|
|
14
15
|
import type { ContentStatus } from '../../schema/types.js'
|
|
15
16
|
import { apiErrorMessage } from '../api-error.js'
|
|
16
17
|
import { Icon } from '../icons.js'
|
|
@@ -40,6 +41,12 @@ export interface PublishTabProps {
|
|
|
40
41
|
authorId: string | null
|
|
41
42
|
/** Current scheduled publish time (ISO string, null if not scheduled). */
|
|
42
43
|
publishAt: string | null
|
|
44
|
+
/**
|
|
45
|
+
* Timezone the editor schedules in. The same zone is used to DISPLAY the
|
|
46
|
+
* stored instant and to SAVE what is typed, so a resave of untouched fields
|
|
47
|
+
* is a true no-op. Defaults to the publication timezone.
|
|
48
|
+
*/
|
|
49
|
+
publishTimezone?: string
|
|
43
50
|
/** Called after a status/field change so the parent can refresh. */
|
|
44
51
|
onChanged?: () => void
|
|
45
52
|
}
|
|
@@ -85,6 +92,7 @@ export function PublishTab({
|
|
|
85
92
|
visibility,
|
|
86
93
|
authorId,
|
|
87
94
|
publishAt,
|
|
95
|
+
publishTimezone = PUBLISH_TIMEZONE_FALLBACK,
|
|
88
96
|
onChanged,
|
|
89
97
|
}: PublishTabProps): React.ReactElement {
|
|
90
98
|
const [saving, setSaving] = useState(false)
|
|
@@ -92,16 +100,20 @@ export function PublishTab({
|
|
|
92
100
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
93
101
|
|
|
94
102
|
// Local schedule date/time inputs.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// The editor thinks in publication-local wall clock, so that is what these
|
|
104
|
+
// inputs show. Falling back to the raw UTC slice keeps an unusable zone
|
|
105
|
+
// readable rather than blank.
|
|
106
|
+
const scheduled = useMemo(() => {
|
|
107
|
+
if (!publishAt) return null
|
|
108
|
+
const ms = Date.parse(publishAt)
|
|
109
|
+
if (!Number.isFinite(ms)) return null
|
|
110
|
+
return formatInstantInZone(ms, publishTimezone)
|
|
111
|
+
}, [publishAt, publishTimezone])
|
|
112
|
+
|
|
113
|
+
const [schedDate, setSchedDate] = useState(() => scheduled?.date ?? publishAt?.slice(0, 10) ?? '')
|
|
114
|
+
const [schedTime, setSchedTime] = useState(
|
|
115
|
+
() => scheduled?.time ?? publishAt?.slice(11, 16) ?? '12:00',
|
|
116
|
+
)
|
|
105
117
|
|
|
106
118
|
// Author list (fetched once on mount).
|
|
107
119
|
const [authors, setAuthors] = useState<AuthorOption[]>([])
|
|
@@ -172,18 +184,14 @@ export function PublishTab({
|
|
|
172
184
|
setSaving(true)
|
|
173
185
|
setActionError(null)
|
|
174
186
|
try {
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// them
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// in that zone's offset, silently shifting publish_at by that zone's
|
|
182
|
-
// current UTC offset on every resave -- including an incidental
|
|
183
|
-
// resave that never touched these fields. 'UTC' makes display and
|
|
184
|
-
// save agree, so a resave of untouched fields is a true no-op.
|
|
187
|
+
// These digits are publication-local wall clock (see the initializers),
|
|
188
|
+
// so they are saved under that SAME zone. Display and save must agree:
|
|
189
|
+
// sending digits under a zone they were not rendered in made the server
|
|
190
|
+
// reinterpret them and shift publish_at by that zone's offset on every
|
|
191
|
+
// resave, including an incidental one that never touched these fields
|
|
192
|
+
// (the 2026-09-08 P0). Agreement is what keeps a resave a no-op.
|
|
185
193
|
const localDateTime = `${schedDate}T${schedTime}`
|
|
186
|
-
const body = buildScheduleBody(localDateTime,
|
|
194
|
+
const body = buildScheduleBody(localDateTime, publishTimezone)
|
|
187
195
|
await runAction(docId, 'schedule', body as unknown as Record<string, unknown>)
|
|
188
196
|
setSelectedStatus('scheduled')
|
|
189
197
|
onChanged?.()
|
|
@@ -192,7 +200,7 @@ export function PublishTab({
|
|
|
192
200
|
} finally {
|
|
193
201
|
setSaving(false)
|
|
194
202
|
}
|
|
195
|
-
}, [docId, schedDate, schedTime, saving, onChanged])
|
|
203
|
+
}, [docId, schedDate, schedTime, saving, onChanged, publishTimezone])
|
|
196
204
|
|
|
197
205
|
const handleVisibilityChange = useCallback(
|
|
198
206
|
async (newVis: 'free' | 'premium') => {
|
|
@@ -317,7 +325,7 @@ export function PublishTab({
|
|
|
317
325
|
{/* Scheduled date + time inputs */}
|
|
318
326
|
{showScheduleControls && (
|
|
319
327
|
<div style={schedGrid} className="fade-only">
|
|
320
|
-
<Field label=
|
|
328
|
+
<Field label={`Date (${publishTimezone})`}>
|
|
321
329
|
<input
|
|
322
330
|
className="input"
|
|
323
331
|
type="date"
|
|
@@ -325,7 +333,7 @@ export function PublishTab({
|
|
|
325
333
|
onChange={(e) => setSchedDate(e.target.value)}
|
|
326
334
|
/>
|
|
327
335
|
</Field>
|
|
328
|
-
<Field label=
|
|
336
|
+
<Field label={`Time (${publishTimezone})`}>
|
|
329
337
|
<input
|
|
330
338
|
className="input"
|
|
331
339
|
type="time"
|