Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
220 lines
7.0 KiB
JavaScript
220 lines
7.0 KiB
JavaScript
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>
|
||
)
|
||
}
|