Files
SkinbaseNova/resources/js/components/upload/SchedulePublishPicker.jsx

205 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}