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,262 @@
<?php
namespace App\Services\Posts;
use App\Models\Post;
use App\Models\PostSave;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class PostFeedService
{
private const PER_PAGE = 20;
// ─────────────────────────────────────────────────────────────────────────
// Profile feed — pinned posts first, then chronological
// ─────────────────────────────────────────────────────────────────────────
public function getProfileFeed(
User $profileUser,
?int $viewerId,
int $page = 1,
): array {
$baseQuery = Post::with($this->eagerLoads())
->where('user_id', $profileUser->id)
->visibleTo($viewerId);
// Pinned posts (always on page 1, regardless of pagination)
$pinned = (clone $baseQuery)
->where('is_pinned', true)
->orderBy('pinned_order')
->get();
$paginated = (clone $baseQuery)
->orderByDesc('created_at')
->paginate(self::PER_PAGE, ['*'], 'page', $page);
// On page 1, prepend pinned posts (deduplicated)
$paginatedCollection = $paginated->getCollection();
if ($page === 1 && $pinned->isNotEmpty()) {
$pinnedIds = $pinned->pluck('id');
$rest = $paginatedCollection->reject(fn ($p) => $pinnedIds->contains($p->id));
$combined = $pinned->concat($rest);
} else {
$combined = $paginatedCollection->reject(fn ($p) => $p->is_pinned && $page === 1);
}
return [
'data' => $combined->values()->all(),
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Following feed (ranked + diversity-limited)
// ─────────────────────────────────────────────────────────────────────────
public function getFollowingFeed(
User $viewer,
int $page = 1,
string $filter = 'all',
): array {
$followingIds = DB::table('user_followers')
->where('follower_id', $viewer->id)
->pluck('user_id')
->toArray();
if (empty($followingIds)) {
return ['data' => [], 'meta' => ['total' => 0, 'current_page' => $page, 'last_page' => 1, 'per_page' => self::PER_PAGE]];
}
$query = Post::with($this->eagerLoads())
->whereIn('user_id', $followingIds)
->visibleTo($viewer->id)
->orderByDesc('created_at');
if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE);
elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT);
elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD);
$paginated = $query->paginate(self::PER_PAGE, ['*'], 'page', $page);
$diversified = $this->applyDiversityPass($paginated->getCollection());
return [
'data' => $diversified->values()->all(),
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Hashtag feed
// ─────────────────────────────────────────────────────────────────────────
public function getHashtagFeed(
string $tag,
?int $viewerId,
int $page = 1,
): array {
$tag = mb_strtolower($tag);
$paginated = Post::with($this->eagerLoads())
->whereHas('hashtags', fn ($q) => $q->where('tag', $tag))
->visibleTo($viewerId)
->orderByDesc('created_at')
->paginate(self::PER_PAGE, ['*'], 'page', $page);
return [
'data' => $paginated->getCollection()->values()->all(),
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Saved posts feed
// ─────────────────────────────────────────────────────────────────────────
public function getSavedFeed(User $viewer, int $page = 1): array
{
$paginated = Post::with($this->eagerLoads())
->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id))
->where('status', Post::STATUS_PUBLISHED)
->orderByDesc('created_at')
->paginate(self::PER_PAGE, ['*'], 'page', $page);
return [
'data' => $paginated->getCollection()->values()->all(),
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/** Eager loads for authenticated/profile feeds. */
private function eagerLoads(): array
{
return [
'user',
'user.profile',
'targets',
'targets.artwork',
'targets.artwork.user',
'targets.artwork.user.profile',
'reactions',
'hashtags',
];
}
/** Eager loads safe for public (trending) feed calls from PostTrendingService. */
public function publicEagerLoads(): array
{
return $this->eagerLoads();
}
/**
* Penalize runs of 5+ posts from the same author by deferring them to the end.
*/
public function applyDiversityPass(Collection $posts): Collection
{
$result = collect(); $deferred = collect(); $runCounts = [];
foreach ($posts as $post) {
$uid = $post->user_id;
$runCounts[$uid] = ($runCounts[$uid] ?? 0) + 1;
($runCounts[$uid] <= 5 ? $result : $deferred)->push($post);
}
return $result->merge($deferred);
}
// ─────────────────────────────────────────────────────────────────────────
// Formatter
// ─────────────────────────────────────────────────────────────────────────
/**
* Serialize a Post into a JSON-safe array for API responses.
*/
public function formatPost(Post $post, ?int $viewerId): array
{
$artworkData = null;
if ($post->type === Post::TYPE_ARTWORK_SHARE) {
$target = $post->targets->firstWhere('target_type', 'artwork');
$artwork = $target?->artwork;
if ($artwork) {
$artworkData = [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumb_url ?? null,
'author' => [
'id' => $artwork->user->id,
'username' => $artwork->user->username,
'name' => $artwork->user->name,
'avatar' => $artwork->user->profile?->avatar_url ?? null,
],
];
}
}
$viewerLiked = $viewerSaved = false;
if ($viewerId) {
$viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty();
// saves are lazy-loaded only when needed; check if relation is loaded
if ($post->relationLoaded('saves')) {
$viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty();
} else {
$viewerSaved = $post->saves()->where('user_id', $viewerId)->exists();
}
}
return [
'id' => $post->id,
'type' => $post->type,
'visibility' => $post->visibility,
'status' => $post->status,
'body' => $post->body,
'reactions_count' => $post->reactions_count,
'comments_count' => $post->comments_count,
'saves_count' => $post->saves_count,
'impressions_count'=> $post->impressions_count,
'is_pinned' => (bool) $post->is_pinned,
'pinned_order' => $post->pinned_order,
'publish_at' => $post->publish_at?->toISOString(),
'viewer_liked' => $viewerLiked,
'viewer_saved' => $viewerSaved,
'artwork' => $artworkData,
'author' => [
'id' => $post->user->id,
'username' => $post->user->username,
'name' => $post->user->name,
'avatar' => $post->user->profile?->avatar_url ?? null,
],
'hashtags' => $post->relationLoaded('hashtags') ? $post->hashtags->pluck('tag')->toArray() : [],
'meta' => $post->meta,
'created_at' => $post->created_at->toISOString(),
'updated_at' => $post->updated_at->toISOString(),
];
}
}