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

@@ -1,6 +1,7 @@
import { useCallback, useReducer, useRef } from 'react'
import { emitUploadEvent } from '../../lib/uploadAnalytics'
import * as uploadEndpoints from '../../lib/uploadEndpoints'
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
@@ -29,6 +30,7 @@ const initialMachineState = {
isCancelling: false,
error: '',
lastAction: null,
slug: null,
}
function machineReducer(state, action) {
@@ -52,7 +54,9 @@ function machineReducer(state, action) {
case 'PUBLISH_START':
return { ...state, state: machineStates.publishing, error: '', lastAction: 'publish' }
case 'PUBLISH_SUCCESS':
return { ...state, state: machineStates.complete, error: '' }
return { ...state, state: machineStates.complete, error: '', slug: action.slug ?? state.slug }
case 'SCHEDULED':
return { ...state, state: machineStates.complete, error: '', lastAction: 'schedule', slug: action.slug ?? state.slug }
case 'CANCEL_START':
return { ...state, isCancelling: true, error: '', lastAction: 'cancel' }
case 'CANCELLED':
@@ -108,6 +112,7 @@ export default function useUploadMachine({
metadata,
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
onArtworkCreated,
onNotice,
}) {
const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState)
@@ -178,16 +183,18 @@ export default function useUploadMachine({
} else if (processingValue === 'rejected' || processingValue === 'error' || payload?.failure_reason) {
const failureMessage = payload?.failure_reason || payload?.message || `Processing ended with status: ${processingValue}`
dispatchMachine({ type: 'ERROR', error: failureMessage })
onNotice?.({ type: 'error', message: failureMessage })
clearPolling()
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || 'Processing status check failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'processing_poll', message })
const notice = mapUploadErrorNotice(error, 'Processing status check failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'processing_poll', message: notice.message })
clearPolling()
}
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling])
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling, onNotice])
const startPolling = useCallback((sessionId, uploadToken) => {
clearPolling()
@@ -290,7 +297,12 @@ export default function useUploadMachine({
const finishController = registerController()
const finishResponse = await window.axios.post(
uploadEndpoints.finish(),
{ session_id: sessionId, upload_token: uploadToken, artwork_id: artworkIdForUpload },
{
session_id: sessionId,
upload_token: uploadToken,
artwork_id: artworkIdForUpload,
file_name: String(primaryFile?.name || ''),
},
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
)
unregisterController(finishController)
@@ -298,6 +310,14 @@ export default function useUploadMachine({
const finishStatus = getProcessingValue(finishResponse?.data || {})
dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus })
const finishNotice = mapUploadResultNotice(finishResponse?.data || {}, {
fallbackType: finishStatus === 'queued' ? 'warning' : 'success',
fallbackMessage: finishStatus === 'queued'
? 'Upload received. Processing is queued.'
: 'Upload completed successfully.',
})
onNotice?.(finishNotice)
if (isReadyToPublishStatus(finishStatus)) {
dispatchMachine({ type: 'READY_TO_PUBLISH' })
} else {
@@ -307,9 +327,10 @@ export default function useUploadMachine({
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || error?.message || 'Upload failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'upload_flow', message })
const notice = mapUploadErrorNotice(error, 'Upload failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'upload_flow', message: notice.message })
}
}, [
primaryFile,
@@ -323,6 +344,7 @@ export default function useUploadMachine({
clearPolling,
startPolling,
onArtworkCreated,
onNotice,
])
// ── Cancel ─────────────────────────────────────────────────────────────────
@@ -341,55 +363,88 @@ export default function useUploadMachine({
)
}
dispatchMachine({ type: 'CANCELLED' })
onNotice?.({ type: 'warning', message: 'Upload cancelled.' })
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
} catch (error) {
const message = error?.response?.data?.message || 'Cancel failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'cancel', message })
const notice = mapUploadErrorNotice(error, 'Cancel failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'cancel', message: notice.message })
}
}, [machine, abortAllRequests, clearPolling])
}, [machine, abortAllRequests, clearPolling, onNotice])
// ── Publish ────────────────────────────────────────────────────────────────
const handlePublish = useCallback(async (canPublish) => {
/**
* handlePublish
*
* @param {boolean} canPublish
* @param {{ mode?: 'now'|'schedule', publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
*/
const handlePublish = useCallback(async (canPublish, opts = {}) => {
if (!canPublish || publishLockRef.current) return
publishLockRef.current = true
dispatchMachine({ type: 'PUBLISH_START' })
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
const buildPayload = () => ({
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
mode,
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
...(timezone ? { timezone } : {}),
visibility,
})
try {
const publishTargetId =
resolvedArtworkIdRef.current || initialDraftId || machine.sessionId
if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) {
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
emitUploadEvent('upload_publish', { id: publishTargetId })
const publishController = registerController()
const publishRes = await window.axios.post(
uploadEndpoints.publish(String(resolvedArtworkIdRef.current)),
buildPayload(),
{ signal: publishController.signal }
)
unregisterController(publishController)
const publishedSlug = publishRes?.data?.slug ?? null
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug })
onNotice?.(mapUploadResultNotice(publishRes?.data || {}, {
fallbackType: 'success',
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
}))
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
return
}
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
await window.axios.post(
const publishRes2 = await window.axios.post(
uploadEndpoints.publish(publishTargetId),
{
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
},
buildPayload(),
{ signal: publishController.signal }
)
unregisterController(publishController)
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
emitUploadEvent('upload_publish', { id: publishTargetId })
const publishedSlug2 = publishRes2?.data?.slug ?? null
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug2 })
onNotice?.(mapUploadResultNotice(publishRes2?.data || {}, {
fallbackType: 'success',
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
}))
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || error?.message || 'Publish failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'publish', message })
const notice = mapUploadErrorNotice(error, 'Publish failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'publish', message: notice.message })
} finally {
publishLockRef.current = false
}
}, [machine, initialDraftId, metadata.title, metadata.description, registerController, unregisterController])
}, [machine, initialDraftId, metadata, registerController, unregisterController, onNotice])
// ── Reset ──────────────────────────────────────────────────────────────────
const resetMachine = useCallback(() => {
@@ -404,11 +459,20 @@ export default function useUploadMachine({
}, [clearPolling, abortAllRequests, initialDraftId])
// ── Retry ──────────────────────────────────────────────────────────────────
const handleRetry = useCallback((canPublish) => {
/**
* handleRetry
*
* Re-attempts the last action. When the last action was a publish/schedule,
* opts must be forwarded so scheduled-publish options are not lost on retry.
*
* @param {boolean} canPublish
* @param {{ mode?: string, publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
*/
const handleRetry = useCallback((canPublish, opts = {}) => {
clearPolling()
abortAllRequests()
if (machine.lastAction === 'publish') {
handlePublish(canPublish)
handlePublish(canPublish, opts)
return
}
runUploadFlow()