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:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View 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>
)
}