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

@@ -446,3 +446,127 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
->post('reports', [\App\Http\Controllers\Api\ReportController::class, 'store'])
->name('api.reports.store');
// ── Profile API (public, throttled) ─────────────────────────────────────────
// GET /api/profile/{username}/artworks?sort=latest|trending|rising|views|favs&cursor=...
// GET /api/profile/{username}/favourites?cursor=...
// GET /api/profile/{username}/stats
Route::middleware(['web', 'throttle:60,1'])
->prefix('profile/{username}')
->name('api.profile.')
->where(['username' => '[A-Za-z0-9_-]{3,20}'])
->group(function () {
Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks');
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
});
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
// GET /api/link-preview?url=... → fetch OG tags for a URL
Route::middleware(['web', 'auth', 'throttle:30,1'])
->get('link-preview', \App\Http\Controllers\Api\LinkPreviewController::class)
->name('api.link-preview');
// ── Posts / Feed System ───────────────────────────────────────────────────────
// Public: profile feed (respects visibility)
// Auth : create/edit/delete posts, following feed, reactions, comments, reports, shares
Route::middleware(['web', 'throttle:60,1'])
->prefix('posts')
->name('api.posts.')
->group(function () {
// Profile feed (public, visibility-filtered)
Route::get('profile/{username}', [\App\Http\Controllers\Api\Posts\PostFeedController::class, 'profile'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile');
// Per-post comments (public)
Route::get('{id}/comments', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'index'])
->whereNumber('id')
->name('comments.index');
});
Route::middleware(['web', 'auth', 'normalize.username'])
->prefix('posts')
->name('api.posts.')
->group(function () {
// Following feed (auth)
Route::get('following', [\App\Http\Controllers\Api\Posts\PostFeedController::class, 'following'])
->middleware('throttle:60,1')
->name('following');
// CRUD
Route::post('/', [\App\Http\Controllers\Api\Posts\PostController::class, 'store'])->name('store');
Route::patch('{id}', [\App\Http\Controllers\Api\Posts\PostController::class, 'update'])->whereNumber('id')->name('update');
Route::delete('{id}', [\App\Http\Controllers\Api\Posts\PostController::class, 'destroy'])->whereNumber('id')->name('destroy');
// Share artwork
Route::post('share/artwork/{artwork_id}', [\App\Http\Controllers\Api\Posts\PostShareController::class, 'shareArtwork'])
->whereNumber('artwork_id')
->middleware('throttle:30,1')
->name('share.artwork');
// Reactions
Route::post('{id}/reactions', [\App\Http\Controllers\Api\Posts\PostReactionController::class, 'store'])
->whereNumber('id')
->middleware('throttle:60,1')
->name('reactions.store');
Route::delete('{id}/reactions/{reaction}', [\App\Http\Controllers\Api\Posts\PostReactionController::class, 'destroy'])
->whereNumber('id')
->middleware('throttle:60,1')
->name('reactions.destroy');
// Comments
Route::post('{id}/comments', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'store'])->whereNumber('id')->name('comments.store');
Route::delete('{id}/comments/{comment_id}', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'destroy'])->whereNumber(['id', 'comment_id'])->name('comments.destroy');
// Reports
Route::post('{id}/report', [\App\Http\Controllers\Api\Posts\PostReportController::class, 'store'])
->whereNumber('id')
->middleware('throttle:10,1')
->name('report');
// ── Feed 2.0 ───────────────────────────────────────────────────────
// Pinned posts
Route::post('{id}/pin', [\App\Http\Controllers\Api\Posts\PostPinController::class, 'pin'])->whereNumber('id')->name('pin');
Route::delete('{id}/pin', [\App\Http\Controllers\Api\Posts\PostPinController::class, 'unpin'])->whereNumber('id')->name('unpin');
// Saves / bookmarks
Route::post('{id}/save', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'save'])->whereNumber('id')->middleware('throttle:60,1')->name('save');
Route::delete('{id}/save', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'unsave'])->whereNumber('id')->middleware('throttle:60,1')->name('unsave');
Route::get('saved', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'index'])->middleware('throttle:60,1')->name('saved');
// Analytics
Route::post('{id}/impression', [\App\Http\Controllers\Api\Posts\PostAnalyticsController::class, 'impression'])->whereNumber('id')->middleware('throttle:120,1')->name('impression');
Route::get('{id}/analytics', [\App\Http\Controllers\Api\Posts\PostAnalyticsController::class, 'show'])->whereNumber('id')->name('analytics');
// Comment highlight
Route::post('{post_id}/comments/{comment_id}/highlight', [\App\Http\Controllers\Api\Posts\PostCommentHighlightController::class, 'highlight'])->whereNumber(['post_id','comment_id'])->name('comments.highlight');
Route::delete('{post_id}/comments/{comment_id}/highlight', [\App\Http\Controllers\Api\Posts\PostCommentHighlightController::class, 'unhighlight'])->whereNumber(['post_id','comment_id'])->name('comments.unhighlight');
});
// ── Feed 2.0: Trending + Hashtag + Search (public) ───────────────────────────
Route::middleware(['web', 'throttle:60,1'])
->prefix('feed')
->name('api.feed.')
->group(function () {
Route::get('trending', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'trending'])->name('trending');
Route::get('hashtag/{tag}', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'hashtag'])->name('hashtag');
Route::get('hashtags/trending', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'trendingHashtags'])->name('hashtags.trending');
Route::get('search', [\App\Http\Controllers\Api\Posts\PostSearchController::class, 'search'])->name('search');
});
// ── Notifications (digest) ────────────────────────────────────────────────────
Route::middleware(['web', 'auth'])
->prefix('notifications')
->name('api.notifications.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Api\NotificationController::class, 'index'])->middleware('throttle:30,1')->name('index');
Route::post('read-all', [\App\Http\Controllers\Api\NotificationController::class, 'readAll'])->name('read-all');
Route::post('{id}/read', [\App\Http\Controllers\Api\NotificationController::class, 'markRead'])->name('mark-read');
});
// ── Artwork search for share modal (public, throttled) ────────────────────────
// GET /api/search/artworks?q=...&shareable=1 → reuses existing ArtworkSearchController