Files
SkinbaseNova/.deploy/artwork-evolution-release/resources/js/components/upload/SchedulePublishPicker.jsx
2026-04-18 17:02:56 +02:00

220 lines
7.0 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'
/**
* 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 [dateStr, setDateStr] = useState(initial.date || '')
const [timeStr, setTimeStr] = useState(initial.time || '')
const [error, setError] = useState('')
const validate = useCallback(
(d, t) => {
if (!d || !t) return 'Date and time are required.'
const iso = localToUtcIso(d, t, 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(() => {
if (mode !== 'schedule') {
setError('')
return
}
if (!dateStr && !timeStr) {
setError('')
onScheduleAt?.(null)
return
}
const err = validate(dateStr, timeStr)
setError(err)
if (!err) {
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
} else {
onScheduleAt?.(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStr, timeStr, mode])
const previewLabel = useMemo(() => {
if (mode !== 'schedule' || error) return null
const iso = localToUtcIso(dateStr, timeStr, timezone)
return formatPreviewLabel(iso, timezone)
}, [mode, error, dateStr, timeStr, 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">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex-1">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
Date
</label>
<input
id="schedule-date"
type="date"
disabled={disabled}
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
min={new Date().toISOString().slice(0, 10)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
<div className="w-28 shrink-0">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
Time
</label>
<input
id="schedule-time"
type="time"
disabled={disabled}
value={timeStr}
onChange={(e) => setTimeStr(e.target.value)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
</div>
<p className="text-[10px] text-white/35">
Timezone: <span className="text-white/55">{timezone}</span>
</p>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{previewLabel && (
<p className="text-xs text-emerald-300/80">
Will publish on: <span className="font-medium">{previewLabel}</span>
</p>
)}
</div>
)}
</div>
)
}