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:
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
92
app/Http/Controllers/Api/Posts/PostController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal 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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user