205 lines
6.4 KiB
JavaScript
205 lines
6.4 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import DateTimePicker from '../ui/DateTimePicker'
|
||
|
||
/**
|
||
* SchedulePublishPicker
|
||
*
|
||
* Toggle between "Publish now" and "Schedule publish".
|
||
* When scheduled, shows a date + time input with validation
|
||
* (must be >= now + 5 minutes).
|
||
*
|
||
* Props:
|
||
* mode 'now' | 'schedule'
|
||
* scheduledAt ISO string | null – current scheduled datetime (UTC)
|
||
* timezone string – IANA tz (e.g. 'Europe/Ljubljana')
|
||
* onModeChange (mode) => void
|
||
* onScheduleAt (iso | null) => void
|
||
* disabled bool
|
||
*/
|
||
function toLocalDateTimeString(isoString, tz) {
|
||
if (!isoString) return { date: '', time: '' }
|
||
try {
|
||
const d = new Date(isoString)
|
||
const opts = { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }
|
||
const dateStr = new Intl.DateTimeFormat('en-CA', opts).format(d) // en-CA gives YYYY-MM-DD
|
||
const timeStr = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: tz,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
}).format(d)
|
||
return { date: dateStr, time: timeStr }
|
||
} catch {
|
||
return { date: '', time: '' }
|
||
}
|
||
}
|
||
|
||
function formatPreviewLabel(isoString, tz) {
|
||
if (!isoString) return null
|
||
try {
|
||
return new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: tz,
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
timeZoneName: 'short',
|
||
}).format(new Date(isoString))
|
||
} catch {
|
||
return isoString
|
||
}
|
||
}
|
||
|
||
function localToUtcIso(dateStr, timeStr, tz) {
|
||
if (!dateStr || !timeStr) return null
|
||
try {
|
||
const dtStr = `${dateStr}T${timeStr}:00`
|
||
const local = new Date(
|
||
new Date(dtStr).toLocaleString('en-US', { timeZone: tz })
|
||
)
|
||
const utcOffset = new Date(dtStr) - local
|
||
const utcDate = new Date(new Date(dtStr).getTime() + utcOffset)
|
||
return utcDate.toISOString()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const MIN_FUTURE_MS = 5 * 60 * 1000 // 5 minutes
|
||
|
||
export default function SchedulePublishPicker({
|
||
mode = 'now',
|
||
scheduledAt = null,
|
||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
onModeChange,
|
||
onScheduleAt,
|
||
disabled = false,
|
||
}) {
|
||
const initial = useMemo(
|
||
() => toLocalDateTimeString(scheduledAt, timezone),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[]
|
||
)
|
||
const [localDateTime, setLocalDateTime] = useState(initial.date && initial.time ? `${initial.date}T${initial.time}` : '')
|
||
const [error, setError] = useState('')
|
||
const minScheduleLocalDateTime = (() => {
|
||
const next = toLocalDateTimeString(new Date(Date.now() + MIN_FUTURE_MS).toISOString(), timezone)
|
||
return next.date && next.time ? `${next.date}T${next.time}` : ''
|
||
})()
|
||
|
||
const validate = useCallback(
|
||
(value) => {
|
||
const [datePart = '', timePart = ''] = String(value || '').split('T')
|
||
if (!datePart || !timePart) return 'Date and time are required.'
|
||
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
|
||
if (!iso) return 'Invalid date or time.'
|
||
const target = new Date(iso)
|
||
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
|
||
if (target.getTime() - Date.now() < MIN_FUTURE_MS) {
|
||
return 'Scheduled time must be at least 5 minutes in the future.'
|
||
}
|
||
return ''
|
||
},
|
||
[timezone]
|
||
)
|
||
|
||
useEffect(() => {
|
||
const next = toLocalDateTimeString(scheduledAt, timezone)
|
||
setLocalDateTime(next.date && next.time ? `${next.date}T${next.time}` : '')
|
||
}, [scheduledAt, timezone])
|
||
|
||
useEffect(() => {
|
||
if (mode !== 'schedule') {
|
||
setError('')
|
||
return
|
||
}
|
||
if (!localDateTime) {
|
||
setError('')
|
||
onScheduleAt?.(null)
|
||
return
|
||
}
|
||
const err = validate(localDateTime)
|
||
setError(err)
|
||
if (!err) {
|
||
const [datePart = '', timePart = ''] = localDateTime.split('T')
|
||
onScheduleAt?.(localToUtcIso(datePart, timePart.slice(0, 5), timezone))
|
||
} else {
|
||
onScheduleAt?.(null)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [localDateTime, mode, timezone])
|
||
|
||
const previewLabel = useMemo(() => {
|
||
if (mode !== 'schedule' || error) return null
|
||
const [datePart = '', timePart = ''] = localDateTime.split('T')
|
||
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
|
||
return formatPreviewLabel(iso, timezone)
|
||
}, [mode, error, localDateTime, timezone])
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2" role="group" aria-label="Publish mode">
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
onModeChange?.('now')
|
||
setError('')
|
||
}}
|
||
className={[
|
||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||
mode === 'now'
|
||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||
].join(' ')}
|
||
aria-pressed={mode === 'now'}
|
||
>
|
||
Publish now
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => onModeChange?.('schedule')}
|
||
className={[
|
||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||
mode === 'schedule'
|
||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||
].join(' ')}
|
||
aria-pressed={mode === 'schedule'}
|
||
>
|
||
Schedule
|
||
</button>
|
||
</div>
|
||
|
||
{mode === 'schedule' && (
|
||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||
<DateTimePicker
|
||
id="schedule-datetime"
|
||
label="Release date and time"
|
||
value={localDateTime}
|
||
onChange={setLocalDateTime}
|
||
placeholder="Pick a release slot"
|
||
disabled={disabled}
|
||
minDateTime={minScheduleLocalDateTime}
|
||
clearable
|
||
hint={`Timezone: ${timezone}`}
|
||
error={error}
|
||
/>
|
||
|
||
{previewLabel && (
|
||
<p className="text-xs text-emerald-300/80">
|
||
Will publish on: <span className="font-medium">{previewLabel}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|