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

@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react'
import TagInput from '../../components/tags/TagInput'
import UploadWizard from '../../components/upload/UploadWizard'
import Checkbox from '../../Components/ui/Checkbox'
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
const phases = {
idle: 'idle',
@@ -179,13 +180,21 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}, [])
const pushNotice = useCallback((type, message) => {
const normalizedType = ['success', 'warning', 'error'].includes(String(type || '').toLowerCase())
? String(type).toLowerCase()
: 'error'
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
dispatch({ type: 'PUSH_NOTICE', notice: { id, type, message } })
dispatch({ type: 'PUSH_NOTICE', notice: { id, type: normalizedType, message } })
window.setTimeout(() => {
dispatch({ type: 'REMOVE_NOTICE', id })
}, 4500)
}, [])
const pushMappedNotice = useCallback((notice) => {
if (!notice?.message) return
pushNotice(notice.type || 'error', notice.message)
}, [pushNotice])
const previewUrl = useMemo(() => {
if (state.previewUrl) return state.previewUrl
if (!state.filePreviewUrl) return null
@@ -276,12 +285,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}
return { sessionId: data.session_id, uploadToken: data.upload_token }
} catch (error) {
const message = extractErrorMessage(error, 'Failed to initialize upload session.')
dispatch({ type: 'INIT_ERROR', error: message })
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'Failed to initialize upload session.')
dispatch({ type: 'INIT_ERROR', error: notice.message })
pushMappedNotice(notice)
return null
}
}, [state.file, userId, extractErrorMessage, pushNotice])
}, [state.file, userId, pushMappedNotice])
const createDraft = useCallback(async () => {
if (state.artworkId) return state.artworkId
@@ -302,12 +311,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}
throw new Error('missing_artwork_id')
} catch (error) {
const message = extractErrorMessage(error, 'Unable to create draft metadata.')
dispatch({ type: 'FINISH_ERROR', error: message })
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'Unable to create draft metadata.')
dispatch({ type: 'FINISH_ERROR', error: notice.message })
pushMappedNotice(notice)
return null
}
}, [state.artworkId, state.metadata, state.draftId, extractErrorMessage, pushNotice])
}, [state.artworkId, state.metadata, state.draftId, pushMappedNotice])
const syncArtworkTags = useCallback(async (artworkId) => {
const tags = Array.from(new Set(parseUiTags(state.metadata.tags)))
@@ -319,11 +328,11 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags })
return true
} catch (error) {
const message = extractErrorMessage(error, 'Tag sync failed. Upload will continue.')
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'Tag sync failed. Upload will continue.')
pushMappedNotice({ ...notice, type: 'warning' })
return false
}
}, [state.metadata.tags, extractErrorMessage, pushNotice])
}, [state.metadata.tags, pushMappedNotice])
const fetchStatus = useCallback(async (sessionId, uploadToken) => {
const res = await window.axios.get(`/api/uploads/status/${sessionId}`, {
@@ -393,9 +402,9 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
try {
status = await fetchStatus(sessionId, uploadToken)
} catch (error) {
const message = extractErrorMessage(error, 'Unable to resume upload.')
dispatch({ type: 'UPLOAD_ERROR', error: message })
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'Unable to resume upload.')
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
pushMappedNotice(notice)
return false
}
@@ -414,39 +423,53 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
offset = nextOffset
}
} catch (error) {
const message = extractErrorMessage(error, 'File upload failed. Please retry.')
dispatch({ type: 'UPLOAD_ERROR', error: message })
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.')
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
pushMappedNotice(notice)
return false
}
}
return true
}, [chunkSize, fetchStatus, uploadChunk])
}, [chunkSize, fetchStatus, uploadChunk, pushMappedNotice])
const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => {
dispatch({ type: 'FINISH_START' })
try {
const res = await window.axios.post(
'/api/uploads/finish',
{ session_id: sessionId, artwork_id: artworkId, upload_token: uploadToken },
{
session_id: sessionId,
artwork_id: artworkId,
upload_token: uploadToken,
file_name: String(state.file?.name || ''),
},
{ headers: { 'X-Upload-Token': uploadToken } }
)
const data = res.data || {}
const previewPath = data.preview_path
const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null
dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl })
const finishNotice = mapUploadResultNotice(data, {
fallbackType: String(data.status || '').toLowerCase() === 'queued' ? 'warning' : 'success',
fallbackMessage: String(data.status || '').toLowerCase() === 'queued'
? 'Upload received. Processing is queued.'
: 'Upload finalized successfully.',
})
pushMappedNotice(finishNotice)
if (userId) {
clearStoredSession(userId)
}
return true
} catch (error) {
const message = extractErrorMessage(error, 'Upload finalization failed.')
dispatch({ type: 'FINISH_ERROR', error: message })
pushNotice('error', message)
const notice = mapUploadErrorNotice(error, 'Upload finalization failed.')
dispatch({ type: 'FINISH_ERROR', error: notice.message })
pushMappedNotice(notice)
return false
}
}, [filesCdnUrl, userId])
}, [filesCdnUrl, userId, pushMappedNotice])
const startUpload = useCallback(async () => {
if (!state.file) {
@@ -529,6 +552,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}
dispatch({ type: 'CANCEL_SUCCESS' })
dispatch({ type: 'RESET' })
pushNotice('warning', 'Upload cancelled.')
return
}
@@ -547,7 +571,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}
dispatch({ type: 'CANCEL_SUCCESS' })
dispatch({ type: 'RESET' })
}, [state.sessionId, state.uploadToken, userId])
pushNotice('warning', 'Upload cancelled.')
}, [state.sessionId, state.uploadToken, userId, pushNotice])
return {
state,
@@ -683,7 +708,9 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
aria-live="polite"
className={`rounded-xl border px-4 py-3 text-sm ${notice.type === 'error'
? 'border-red-500/40 bg-red-500/10 text-red-100'
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
: notice.type === 'warning'
? 'border-amber-400/40 bg-amber-400/10 text-amber-100'
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
>
{notice.message}
</div>