feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
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
This commit is contained in:
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user