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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user