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,44 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Services\Posts\PostAnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/posts/{id}/impression record an impression (throttled)
* GET /api/posts/{id}/analytics owner analytics summary
*/
class PostAnalyticsController extends Controller
{
public function __construct(private PostAnalyticsService $analytics) {}
public function impression(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
// Session key: authenticated user ID or hashed IP
$sessionKey = $request->user()
? 'u:' . $request->user()->id
: 'ip:' . md5($request->ip());
$counted = $this->analytics->trackImpression($post, $sessionKey);
return response()->json(['counted' => $counted]);
}
public function show(Request $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
// Only the post owner can view analytics
if ($request->user()?->id !== $post->user_id) {
abort(403, 'You do not own this post.');
}
return response()->json(['data' => $this->analytics->getSummary($post)]);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Events\Posts\PostCommented;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\CreateCommentRequest;
use App\Models\Post;
use App\Models\PostComment;
use App\Services\ContentSanitizer;
use App\Services\Posts\PostCountersService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
class PostCommentController extends Controller
{
public function __construct(private PostCountersService $counters) {}
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $postId): JsonResponse
{
$post = Post::findOrFail($postId);
$page = max(1, (int) $request->query('page', 1));
$comments = PostComment::with(['user', 'user.profile'])
->where('post_id', $post->id)
->orderByDesc('is_highlighted') // highlighted first
->orderBy('created_at')
->paginate(20, ['*'], 'page', $page);
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
return response()->json([
'data' => $formatted,
'meta' => [
'total' => $comments->total(),
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(CreateCommentRequest $request, int $postId): JsonResponse
{
$user = $request->user();
// Rate limit: 30 comments per hour
$key = 'comment_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 30)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
$post = Post::findOrFail($postId);
$body = ContentSanitizer::render($request->input('body'));
$comment = PostComment::create([
'post_id' => $post->id,
'user_id' => $user->id,
'body' => $body,
]);
$this->counters->incrementComments($post);
// Fire event for notification
if ($post->user_id !== $user->id) {
event(new PostCommented($post, $comment, $user));
}
$comment->load(['user', 'user.profile']);
return response()->json(['comment' => $this->formatComment($comment)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Destroy
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
{
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
$this->counters->decrementComments(Post::findOrFail($postId));
return response()->json(['message' => 'Comment deleted.']);
}
// ─────────────────────────────────────────────────────────────────────────
// Format
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(PostComment $comment): array
{
return [
'id' => $comment->id,
'body' => $comment->body,
'is_highlighted' => (bool) $comment->is_highlighted,
'created_at' => $comment->created_at->toISOString(),
'author' => [
'id' => $comment->user->id,
'username' => $comment->user->username,
'name' => $comment->user->name,
'avatar' => $comment->user->profile?->avatar_url ?? null,
],
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostComment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
*
* Only the post owner may highlight/un-highlight.
* Only 1 highlighted comment per post is allowed at a time.
*/
class PostCommentHighlightController extends Controller
{
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
{
$post = Post::findOrFail($postId);
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
if ($request->user()->id !== $post->user_id) {
abort(403, 'Only the post owner can highlight comments.');
}
DB::transaction(function () use ($post, $comment) {
// Remove any existing highlight on this post
PostComment::where('post_id', $post->id)
->where('is_highlighted', true)
->update(['is_highlighted' => false]);
$comment->update(['is_highlighted' => true]);
});
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
}
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
{
$post = Post::findOrFail($postId);
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
if ($request->user()->id !== $post->user_id) {
abort(403, 'Only the post owner can remove comment highlights.');
}
$comment->update(['is_highlighted' => false]);
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\CreatePostRequest;
use App\Http\Requests\Posts\UpdatePostRequest;
use App\Models\Post;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
class PostController extends Controller
{
public function __construct(
private PostService $postService,
private PostFeedService $feedService,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Create
// ─────────────────────────────────────────────────────────────────────────
public function store(CreatePostRequest $request): JsonResponse
{
$user = $request->user();
// Rate limit: 10 post creations per hour
$key = 'create_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
Gate::authorize('create', Post::class);
$post = $this->postService->createPost(
user: $user,
type: $request->input('type', Post::TYPE_TEXT),
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
body: $request->input('body'),
targets: $request->input('targets', []),
linkPreview: $request->input('link_preview'),
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
return response()->json([
'post' => $this->feedService->formatPost($post, $user->id),
], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(UpdatePostRequest $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('update', $post);
$updated = $this->postService->updatePost(
post: $post,
body: $request->input('body'),
visibility: $request->input('visibility'),
);
return response()->json([
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('delete', $post);
$this->postService->deletePost($post);
return response()->json(['message' => 'Post deleted.']);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostFeedController extends Controller
{
public function __construct(private PostFeedService $feedService) {}
// ─────────────────────────────────────────────────────────────────────────
// Profile feed — GET /api/posts/profile/{username}
// ─────────────────────────────────────────────────────────────────────────
public function profile(Request $request, string $username): JsonResponse
{
$profileUser = User::where('username', $username)->firstOrFail();
$viewerId = $request->user()?->id;
$page = max(1, (int) $request->query('page', 1));
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
$formatted = collect($paginated['data'])
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'meta' => $paginated['meta'],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Following feed — GET /api/posts/following
// ─────────────────────────────────────────────────────────────────────────
public function following(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->query('page', 1));
$filter = $request->query('filter', 'all');
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
$viewerId = $user->id;
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewerId),
$result['data'],
);
return response()->json([
'data' => $formatted,
'meta' => $result['meta'],
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
/**
* POST /api/posts/{id}/pin
* DELETE /api/posts/{id}/pin
*/
class PostPinController extends Controller
{
private const MAX_PINNED = 3;
public function pin(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
Gate::authorize('update', $post);
$user = $request->user();
// Count existing pinned posts
$pinnedCount = Post::where('user_id', $user->id)
->where('is_pinned', true)
->count();
if ($post->is_pinned) {
return response()->json(['message' => 'Post is already pinned.'], 409);
}
if ($pinnedCount >= self::MAX_PINNED) {
return response()->json([
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
], 422);
}
$nextOrder = Post::where('user_id', $user->id)
->where('is_pinned', true)
->max('pinned_order') ?? 0;
$post->update([
'is_pinned' => true,
'pinned_order' => $nextOrder + 1,
]);
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
}
public function unpin(int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('update', $post);
if (! $post->is_pinned) {
return response()->json(['message' => 'Post is not pinned.'], 409);
}
$post->update(['is_pinned' => false, 'pinned_order' => null]);
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostReaction;
use App\Services\Posts\PostCountersService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class PostReactionController extends Controller
{
public function __construct(private PostCountersService $counters) {}
/**
* POST /api/posts/{id}/reactions
* payload: { reaction: 'like' }
*/
public function store(Request $request, int $id): JsonResponse
{
$user = $request->user();
$key = 'react_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 60)) {
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
}
RateLimiter::hit($key, 3600);
$post = Post::findOrFail($id);
$reaction = $request->input('reaction', 'like');
$existing = PostReaction::where('post_id', $post->id)
->where('user_id', $user->id)
->where('reaction', $reaction)
->first();
if ($existing) {
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
}
PostReaction::create([
'post_id' => $post->id,
'user_id' => $user->id,
'reaction' => $reaction,
]);
$this->counters->incrementReactions($post);
$post->refresh();
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
}
/**
* DELETE /api/posts/{id}/reactions/{reaction}
*/
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
{
$user = $request->user();
$post = Post::findOrFail($id);
$deleted = PostReaction::where('post_id', $post->id)
->where('user_id', $user->id)
->where('reaction', $reaction)
->delete();
if ($deleted) {
$this->counters->decrementReactions($post);
$post->refresh();
}
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostReport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PostReportController extends Controller
{
/**
* POST /api/posts/{id}/report
* payload: { reason, message? }
*/
public function store(Request $request, int $id): JsonResponse
{
$user = $request->user();
$post = Post::findOrFail($id);
Gate::authorize('report', $post);
$request->validate([
'reason' => ['required', 'string', 'max:64'],
'message' => ['nullable', 'string', 'max:1000'],
]);
// Unique report per user+post
$existing = PostReport::where('post_id', $post->id)
->where('reporter_user_id', $user->id)
->exists();
if ($existing) {
return response()->json(['message' => 'You have already reported this post.'], 409);
}
PostReport::create([
'post_id' => $post->id,
'reporter_user_id' => $user->id,
'reason' => $request->input('reason'),
'message' => $request->input('message'),
'status' => 'open',
]);
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostSave;
use App\Services\Posts\PostCountersService;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/posts/{id}/save
* DELETE /api/posts/{id}/save
* GET /api/posts/saved
*/
class PostSaveController extends Controller
{
public function __construct(
private PostCountersService $counters,
private PostFeedService $feedService,
) {}
public function save(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
$user = $request->user();
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
}
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
$this->counters->incrementSaves($post);
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
}
public function unsave(Request $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
$user = $request->user();
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
if (! $save) {
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
}
$save->delete();
$this->counters->decrementSaves($post);
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->query('page', 1));
$result = $this->feedService->getSavedFeed($user, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $user->id),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/feed/search?q=...
*
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
* Falls back to a simple LIKE query if Scout is unavailable.
*/
class PostSearchController extends Controller
{
public function __construct(private PostFeedService $feedService) {}
public function search(Request $request): JsonResponse
{
$request->validate([
'q' => ['required', 'string', 'min:2', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = trim($request->input('q'));
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$viewerId = $request->user()?->id;
// Scout search (Meilisearch)
try {
$results = Post::search($query)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where('status', Post::STATUS_PUBLISHED)
->paginate($perPage, 'page', $page);
// Load relations
$results->load($this->feedService->publicEagerLoads());
$formatted = $results->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'query' => $query,
'meta' => [
'total' => $results->total(),
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
],
]);
} catch (\Exception $e) {
// Fallback: basic LIKE search on body
$paginated = Post::with($this->feedService->publicEagerLoads())
->where('status', Post::STATUS_PUBLISHED)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where(function ($q) use ($query) {
$q->where('body', 'like', '%' . $query . '%')
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
})
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$formatted = $paginated->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'query' => $query,
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
]);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Events\Posts\ArtworkShared;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\ShareArtworkRequest;
use App\Models\Artwork;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostShareService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
class PostShareController extends Controller
{
public function __construct(
private PostShareService $shareService,
private PostFeedService $feedService,
) {}
/**
* POST /api/posts/share/artwork/{artwork_id}
* payload: { body?, visibility }
*/
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($artworkId);
// Rate limit: 10 artwork shares per hour
$key = 'share_artwork:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
$post = $this->shareService->shareArtwork(
user: $user,
artwork: $artwork,
body: $request->input('body'),
visibility: $request->input('visibility', 'public'),
);
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
// Notify original artwork owner (unless self-share)
if ($artwork->user_id !== $user->id) {
event(new ArtworkShared($post, $artwork, $user));
}
return response()->json([
'post' => $this->feedService->formatPost($post, $user->id),
], 201);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostHashtagService;
use App\Services\Posts\PostTrendingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/feed/trending
* GET /api/feed/hashtag/{tag}
* GET /api/feed/hashtags/trending
*/
class PostTrendingFeedController extends Controller
{
public function __construct(
private PostTrendingService $trendingService,
private PostFeedService $feedService,
private PostHashtagService $hashtagService,
) {}
public function trending(Request $request): JsonResponse
{
$page = max(1, (int) $request->query('page', 1));
$viewer = $request->user()?->id;
$result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
}
public function hashtag(Request $request, string $tag): JsonResponse
{
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
if (strlen($tag) < 2 || strlen($tag) > 64) {
return response()->json(['message' => 'Invalid hashtag.'], 422);
}
$page = max(1, (int) $request->query('page', 1));
$viewer = $request->user()?->id;
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json([
'tag' => $tag,
'data' => array_values($formatted),
'meta' => $result['meta'],
]);
}
public function trendingHashtags(): JsonResponse
{
$tags = Cache::remember('trending_hashtags', 300, function () {
return $this->hashtagService->trending(10, 24);
});
return response()->json(['hashtags' => $tags]);
}
}