messages implemented
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Report;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ModerationReportQueueController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', 'open');
|
||||
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
|
||||
|
||||
$items = Report::query()
|
||||
->with('reporter:id,username')
|
||||
->where('status', $status)
|
||||
->orderByDesc('id')
|
||||
->paginate(30);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
178
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\LegacySmileyMapper;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Artwork comment CRUD.
|
||||
*
|
||||
* POST /api/artworks/{artworkId}/comments → store
|
||||
* PUT /api/artworks/{artworkId}/comments/{id} → update (own comment)
|
||||
* DELETE /api/artworks/{artworkId}/comments/{id} → delete (own or admin)
|
||||
* GET /api/artworks/{artworkId}/comments → list (paginated)
|
||||
*/
|
||||
class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
|
||||
$comments = ArtworkComment::with(['user', 'user.profile'])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$userId = $request->user()?->id;
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
|
||||
// Validate markdown-lite content
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
}
|
||||
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment = ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
'is_approved' => true, // auto-approve; extend with moderation as needed
|
||||
]);
|
||||
|
||||
// Bust the comments cache for this user's 'all' feed
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = ArtworkComment::where('artwork_id', $artworkId)
|
||||
->findOrFail($commentId);
|
||||
|
||||
Gate::authorize('update', $comment);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
}
|
||||
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment->update([
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
]);
|
||||
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
|
||||
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.'], 200);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId): array
|
||||
{
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -14,6 +16,8 @@ final class ArtworkInteractionController extends Controller
|
||||
{
|
||||
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'user_favorites',
|
||||
@@ -25,6 +29,18 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
// Update creator's favorites_received_count
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if ($creatorId) {
|
||||
$svc = app(UserStatsService::class);
|
||||
if ($state) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
} else {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
@@ -72,41 +88,25 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
public function follow(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('friends_list')) {
|
||||
return response()->json(['message' => 'Follow unavailable'], 422);
|
||||
}
|
||||
|
||||
$actorId = (int) $request->user()->id;
|
||||
|
||||
if ($actorId === $userId) {
|
||||
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$query = DB::table('friends_list')
|
||||
->where('user_id', $actorId)
|
||||
->where('friend_id', $userId);
|
||||
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table('friends_list')->insert([
|
||||
'user_id' => $actorId,
|
||||
'friend_id' => $userId,
|
||||
'date_added' => now(),
|
||||
]);
|
||||
}
|
||||
$svc->follow($actorId, $userId);
|
||||
} else {
|
||||
$query->delete();
|
||||
$svc->unfollow($actorId, $userId);
|
||||
}
|
||||
|
||||
$followersCount = (int) DB::table('friends_list')
|
||||
->where('friend_id', $userId)
|
||||
->count();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_following' => $state,
|
||||
'followers_count' => $followersCount,
|
||||
'ok' => true,
|
||||
'is_following' => $state,
|
||||
'followers_count' => $svc->followersCount($userId),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
137
app/Http/Controllers/Api/FollowController.php
Normal file
137
app/Http/Controllers/Api/FollowController.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\FollowService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* API endpoints for the follow system.
|
||||
*
|
||||
* POST /api/user/{username}/follow → follow a user
|
||||
* DELETE /api/user/{username}/follow → unfollow a user
|
||||
* GET /api/user/{username}/followers → paginated followers list
|
||||
* GET /api/user/{username}/following → paginated following list
|
||||
*/
|
||||
final class FollowController extends Controller
|
||||
{
|
||||
public function __construct(private readonly FollowService $followService) {}
|
||||
|
||||
// ─── POST /api/user/{username}/follow ────────────────────────────────────
|
||||
|
||||
public function follow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$actor = Auth::user();
|
||||
|
||||
if ($actor->id === $target->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->followService->follow((int) $actor->id, (int) $target->id);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => true,
|
||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
|
||||
|
||||
public function unfollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$actor = Auth::user();
|
||||
|
||||
$this->followService->unfollow((int) $actor->id, (int) $target->id);
|
||||
|
||||
return response()->json([
|
||||
'following' => false,
|
||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── GET /api/user/{username}/followers ──────────────────────────────────
|
||||
|
||||
public function followers(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.user_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->through(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'display_name'=> $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
}
|
||||
|
||||
// ─── GET /api/user/{username}/following ──────────────────────────────────
|
||||
|
||||
public function following(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.follower_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->through(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'display_name'=> $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function resolveUser(string $username): User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal file
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LatestCommentsApiController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'all');
|
||||
|
||||
// Validate filter type
|
||||
if (! in_array($type, ['all', 'following', 'mine'], true)) {
|
||||
$type = 'all';
|
||||
}
|
||||
|
||||
// 'mine' and 'following' require auth
|
||||
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->orderByDesc('artwork_comments.created_at');
|
||||
|
||||
switch ($type) {
|
||||
case 'mine':
|
||||
$query->where('artwork_comments.user_id', $request->user()->id);
|
||||
break;
|
||||
|
||||
case 'following':
|
||||
$followingIds = $request->user()
|
||||
->following()
|
||||
->pluck('users.id');
|
||||
$query->whereIn('artwork_comments.user_id', $followingIds);
|
||||
break;
|
||||
|
||||
default:
|
||||
// 'all' — cache the first page only
|
||||
if ((int) $request->query('page', 1) === 1) {
|
||||
$cacheKey = 'comments.latest.all.page1';
|
||||
$ttl = 120; // 2 minutes
|
||||
|
||||
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
|
||||
} else {
|
||||
$paginator = $query->paginate(self::PER_PAGE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (! isset($paginator)) {
|
||||
$paginator = $query->paginate(self::PER_PAGE);
|
||||
}
|
||||
|
||||
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
|
||||
$art = $c->artwork;
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url'] ?? null) : null;
|
||||
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
return [
|
||||
'comment_id' => $c->getKey(),
|
||||
'comment_text' => e(strip_tags($c->content ?? '')),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
|
||||
'commenter' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username ?? null,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
],
|
||||
|
||||
'artwork' => $art ? [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
|
||||
'thumb' => $thumb,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal file
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MessageAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$attachment = MessageAttachment::query()
|
||||
->with('message:id,conversation_id')
|
||||
->findOrFail($id);
|
||||
|
||||
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
|
||||
abort_if($conversationId <= 0, 404, 'Attachment not available.');
|
||||
|
||||
$authorized = \App\Models\ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
|
||||
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
$disk = Storage::disk($diskName);
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $attachment): void {
|
||||
echo $disk->get($attachment->storage_path);
|
||||
}, 200, [
|
||||
'Content-Type' => $attachment->mime,
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
|
||||
'Content-Length' => (string) $attachment->size_bytes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal file
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\MessageNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->integer('page', 1));
|
||||
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
return Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->addSelect([
|
||||
'unread_count' => Message::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
||||
->where('messages.sender_id', '!=', $user->id)
|
||||
->whereNull('messages.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('cp_me.last_read_at')
|
||||
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
|
||||
}),
|
||||
])
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
'latestMessage.sender:id,username',
|
||||
])
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id')
|
||||
->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
$conv->my_participant = $conv->allParticipants
|
||||
->firstWhere('user_id', $user->id);
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json($conversations);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
|
||||
$conv->load([
|
||||
'allParticipants.user:id,username',
|
||||
'creator:id,username',
|
||||
]);
|
||||
|
||||
return response()->json($conv);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = $request->validate([
|
||||
'type' => 'required|in:direct,group',
|
||||
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
|
||||
'participant_ids' => 'required_if:type,group|array|min:2',
|
||||
'participant_ids.*'=> 'integer|exists:users,id',
|
||||
'title' => 'required_if:type,group|nullable|string|max:120',
|
||||
'body' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
if ($data['type'] === 'direct') {
|
||||
return $this->createDirect($request, $user, $data);
|
||||
}
|
||||
|
||||
return $this->createGroup($request, $user, $data);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
|
||||
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['last_read_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
|
||||
|
||||
public function archive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_archived' => ! $participant->is_archived]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_archived' => $participant->is_archived]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
|
||||
|
||||
public function mute(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_muted' => ! $participant->is_muted]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_muted' => $participant->is_muted]);
|
||||
}
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_pinned' => false]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
|
||||
|
||||
public function leave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if ($conv->isGroup()) {
|
||||
// Last admin protection
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
if ($adminCount === 1 && $participant->role === 'admin') {
|
||||
$otherMember = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', '!=', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($otherMember) {
|
||||
$otherMember->update(['role' => 'admin']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$participant->update(['left_at' => now()]);
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
|
||||
|
||||
public function addUser(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$this->requireAdmin($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
|
||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->left_at) {
|
||||
$existing->update(['left_at' => null, 'joined_at' => now()]);
|
||||
}
|
||||
} else {
|
||||
ConversationParticipant::create([
|
||||
'conversation_id' => $id,
|
||||
'user_id' => $data['user_id'],
|
||||
'role' => 'member',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$participantUserIds[] = (int) $data['user_id'];
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
|
||||
|
||||
public function removeUser(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
// Cannot remove the conversation creator
|
||||
$conv = Conversation::findOrFail($id);
|
||||
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
|
||||
|
||||
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($targetParticipant && $targetParticipant->role === 'admin') {
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
|
||||
}
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->update(['left_at' => now()]);
|
||||
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
|
||||
|
||||
public function rename(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validate(['title' => 'required|string|max:120']);
|
||||
$conv->update(['title' => $data['title']]);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['title' => $conv->title]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createDirect(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$recipient = User::findOrFail($data['recipient_id']);
|
||||
|
||||
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
|
||||
|
||||
if (! $recipient->allowsMessagesFrom($user)) {
|
||||
abort(403, 'This user does not accept messages from you.');
|
||||
}
|
||||
|
||||
$this->assertNotBlockedBetween($user, $recipient);
|
||||
|
||||
// Reuse existing conversation if one exists
|
||||
$conv = Conversation::findDirect($user->id, $recipient->id);
|
||||
|
||||
if (! $conv) {
|
||||
$conv = DB::transaction(function () use ($user, $recipient) {
|
||||
$conv = Conversation::create([
|
||||
'type' => 'direct',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
}
|
||||
|
||||
// Insert first / next message
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
|
||||
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
|
||||
|
||||
return response()->json($conv->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function createGroup(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
|
||||
|
||||
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
|
||||
$conv = Conversation::create([
|
||||
'type' => 'group',
|
||||
'title' => $data['title'],
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
$rows = array_map(fn ($uid) => [
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $uid,
|
||||
'role' => $uid === $user->id ? 'admin' : 'member',
|
||||
'joined_at' => now(),
|
||||
], $participantIds);
|
||||
|
||||
ConversationParticipant::insert($rows);
|
||||
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
|
||||
return [$conv, $message];
|
||||
});
|
||||
|
||||
[$conversation, $message] = $conv;
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
|
||||
$this->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function findAuthorized(Request $request, int $id): Conversation
|
||||
{
|
||||
$conv = Conversation::findOrFail($id);
|
||||
$this->assertParticipant($request, $id);
|
||||
return $conv;
|
||||
}
|
||||
|
||||
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
|
||||
{
|
||||
return ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function requireAdmin(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'Only admins can perform this action.'
|
||||
);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
foreach (array_unique($userIds) as $userId) {
|
||||
if (! $userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionKey = $this->cacheVersionKey((int) $userId);
|
||||
Cache::add($versionKey, 1, now()->addDay());
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
{
|
||||
return "messages:conversations:version:{$userId}";
|
||||
}
|
||||
|
||||
private function conversationListCacheKey(int $userId, int $page, int $version): string
|
||||
{
|
||||
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
|
||||
}
|
||||
|
||||
private function assertNotBlockedBetween(User $sender, User $recipient): void
|
||||
{
|
||||
if (! Schema::hasTable('user_blocks')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
|
||||
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
abort_if($blocked, 403, 'Messaging is not available between these users.');
|
||||
}
|
||||
}
|
||||
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal file
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageAttachment;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use App\Services\Messaging\MessageNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$cursor = $request->integer('cursor');
|
||||
|
||||
$query = Message::withTrashed()
|
||||
->where('conversation_id', $conversationId)
|
||||
->with(['sender:id,username', 'reactions', 'attachments'])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($cursor) {
|
||||
$query->where('id', '<', $cursor);
|
||||
}
|
||||
|
||||
$chunk = $query->limit(self::PAGE_SIZE + 1)->get();
|
||||
$hasMore = $chunk->count() > self::PAGE_SIZE;
|
||||
$messages = $chunk->take(self::PAGE_SIZE)->reverse()->values();
|
||||
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
|
||||
|
||||
public function store(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => 'nullable|string|max:5000',
|
||||
'attachments' => 'sometimes|array|max:5',
|
||||
'attachments.*' => 'file|max:25600',
|
||||
]);
|
||||
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
$files = $request->file('attachments', []);
|
||||
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversationId,
|
||||
'sender_id' => $request->user()->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file instanceof UploadedFile) {
|
||||
$this->storeAttachment($file, $message, (int) $request->user()->id);
|
||||
}
|
||||
}
|
||||
|
||||
Conversation::where('id', $conversationId)
|
||||
->update(['last_message_at' => $message->created_at]);
|
||||
|
||||
$conversation = Conversation::findOrFail($conversationId);
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
|
||||
app(MessageSearchIndexer::class)->indexMessage($message);
|
||||
event(new MessageSent($conversationId, $message->id, $request->user()->id));
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
$message->load(['sender:id,username', 'attachments']);
|
||||
|
||||
return response()->json($message, 201);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
|
||||
|
||||
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
|
||||
|
||||
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function reactByMessage(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function unreactByMessage(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
|
||||
|
||||
public function update(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id,
|
||||
403,
|
||||
'You may only edit your own messages.'
|
||||
);
|
||||
|
||||
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
|
||||
|
||||
$data = $request->validate(['body' => 'required|string|max:5000']);
|
||||
|
||||
$message->update([
|
||||
'body' => $data['body'],
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json($message->fresh());
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
|
||||
403,
|
||||
'You may only delete your own messages.'
|
||||
);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||
$message->delete();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
foreach (array_unique($userIds) as $userId) {
|
||||
if (! $userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionKey = "messages:conversations:version:{$userId}";
|
||||
Cache::add($versionKey, 1, now()->addDay());
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertAllowedReaction(string $reaction): void
|
||||
{
|
||||
$allowed = (array) config('messaging.reactions.allowed', []);
|
||||
abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.');
|
||||
}
|
||||
|
||||
private function reactionSummary(int $messageId, int $userId): array
|
||||
{
|
||||
$rows = MessageReaction::query()
|
||||
->selectRaw('reaction, count(*) as aggregate_count')
|
||||
->where('message_id', $messageId)
|
||||
->groupBy('reaction')
|
||||
->get();
|
||||
|
||||
$summary = [];
|
||||
foreach ($rows as $row) {
|
||||
$summary[(string) $row->reaction] = (int) $row->aggregate_count;
|
||||
}
|
||||
|
||||
$mine = MessageReaction::query()
|
||||
->where('message_id', $messageId)
|
||||
->where('user_id', $userId)
|
||||
->pluck('reaction')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$summary['me'] = $mine;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
|
||||
{
|
||||
$mime = (string) $file->getMimeType();
|
||||
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
|
||||
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
|
||||
|
||||
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
|
||||
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
|
||||
|
||||
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
|
||||
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
|
||||
|
||||
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
|
||||
|
||||
$maxBytes = $type === 'image'
|
||||
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
|
||||
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
|
||||
|
||||
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
|
||||
|
||||
$year = now()->format('Y');
|
||||
$month = now()->format('m');
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
|
||||
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
|
||||
|
||||
$width = null;
|
||||
$height = null;
|
||||
if ($type === 'image') {
|
||||
$dimensions = @getimagesize($file->getPathname());
|
||||
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
|
||||
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
|
||||
}
|
||||
|
||||
MessageAttachment::query()->create([
|
||||
'message_id' => $message->id,
|
||||
'user_id' => $userId,
|
||||
'type' => $type,
|
||||
'mime' => $detectedMime,
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'sha256' => hash_file('sha256', $file->getPathname()),
|
||||
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
|
||||
'storage_path' => $path,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal file
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Meilisearch\Client;
|
||||
|
||||
class MessageSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageSearchIndexer $indexer,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validate([
|
||||
'q' => 'required|string|min:1|max:200',
|
||||
'conversation_id' => 'nullable|integer|exists:conversations,id',
|
||||
'cursor' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$allowedConversationIds = ConversationParticipant::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->pluck('conversation_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
|
||||
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
|
||||
abort(403, 'You are not a participant of this conversation.');
|
||||
}
|
||||
|
||||
if (empty($allowedConversationIds)) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null]);
|
||||
}
|
||||
|
||||
$limit = max(1, (int) config('messaging.search.page_size', 20));
|
||||
$offset = max(0, (int) ($data['cursor'] ?? 0));
|
||||
|
||||
$hits = collect();
|
||||
$estimated = 0;
|
||||
|
||||
try {
|
||||
$client = new Client(
|
||||
config('scout.meilisearch.host'),
|
||||
config('scout.meilisearch.key')
|
||||
);
|
||||
|
||||
$prefix = (string) config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
|
||||
|
||||
$conversationFilter = $conversationId !== null
|
||||
? "conversation_id = {$conversationId}"
|
||||
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
|
||||
|
||||
$result = $client
|
||||
->index($indexName)
|
||||
->search((string) $data['q'], [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'sort' => ['created_at:desc'],
|
||||
'filter' => $conversationFilter,
|
||||
]);
|
||||
|
||||
$hits = collect($result->getHits() ?? []);
|
||||
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
|
||||
} catch (\Throwable) {
|
||||
$query = Message::query()
|
||||
->select('id')
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
|
||||
->where('body', 'like', '%' . (string) $data['q'] . '%')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
$estimated = (clone $query)->count();
|
||||
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
|
||||
}
|
||||
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$messages = Message::query()
|
||||
->whereIn('id', $messageIds)
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->whereNull('deleted_at')
|
||||
->with(['sender:id,username', 'attachments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = $hits
|
||||
->map(function (array $hit) use ($messages) {
|
||||
$message = $messages->get((int) ($hit['id'] ?? 0));
|
||||
if (! $message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'conversation_id' => $message->conversation_id,
|
||||
'sender_id' => $message->sender_id,
|
||||
'sender' => $message->sender,
|
||||
'body' => $message->body,
|
||||
'created_at' => optional($message->created_at)?->toISOString(),
|
||||
'has_attachments' => $message->attachments->isNotEmpty(),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $ordered,
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rebuild(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
||||
|
||||
$conversationId = $request->integer('conversation_id');
|
||||
if ($conversationId > 0) {
|
||||
$this->indexer->rebuildConversation($conversationId);
|
||||
return response()->json(['queued' => true, 'scope' => 'conversation']);
|
||||
}
|
||||
|
||||
$this->indexer->rebuildAll();
|
||||
|
||||
return response()->json(['queued' => true, 'scope' => 'all']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Manages per-user messaging privacy preference.
|
||||
*
|
||||
* GET /api/messages/settings → return current setting
|
||||
* PATCH /api/messages/settings → update setting
|
||||
*/
|
||||
class MessagingSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
|
||||
]);
|
||||
|
||||
$request->user()->update($data);
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal file
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\TypingStarted;
|
||||
use App\Events\TypingStopped;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TypingController extends Controller
|
||||
{
|
||||
public function start(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
|
||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStarted($conversationId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function stop(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStopped($conversationId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
$participants = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->where('user_id', '!=', $userId)
|
||||
->with('user:id,username')
|
||||
->get();
|
||||
|
||||
$typing = $participants
|
||||
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
|
||||
->map(fn ($p) => [
|
||||
'user_id' => (int) $p->user_id,
|
||||
'username' => (string) ($p->user->username ?? ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['typing' => $typing]);
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function key(int $conversationId, int $userId): string
|
||||
{
|
||||
return "typing:{$conversationId}:{$userId}";
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.typing.cache_store', 'redis');
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Http/Controllers/Api/ReactionController.php
Normal file
192
app/Http/Controllers/Api/ReactionController.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ArtworkReaction;
|
||||
use App\Models\CommentReaction;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Handles reaction toggling for artworks and comments.
|
||||
*
|
||||
* POST /api/artworks/{id}/reactions → toggle artwork reaction
|
||||
* POST /api/comments/{id}/reactions → toggle comment reaction
|
||||
* GET /api/artworks/{id}/reactions → list artwork reactions
|
||||
* GET /api/comments/{id}/reactions → list comment reactions
|
||||
*/
|
||||
class ReactionController extends Controller
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Artwork reactions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function artworkReactions(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
return $this->listReactions('artwork', $artworkId, $request->user()?->id);
|
||||
}
|
||||
|
||||
public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->validateExists('artworks', $artworkId);
|
||||
$slug = $this->validateReactionSlug($request);
|
||||
|
||||
return $this->toggle(
|
||||
model: new ArtworkReaction(),
|
||||
where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug],
|
||||
countWhere: ['artwork_id' => $artworkId],
|
||||
entityId: $artworkId,
|
||||
entityType: 'artwork',
|
||||
userId: $request->user()->id,
|
||||
slug: $slug,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Comment reactions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function commentReactions(Request $request, int $commentId): JsonResponse
|
||||
{
|
||||
return $this->listReactions('comment', $commentId, $request->user()?->id);
|
||||
}
|
||||
|
||||
public function toggleCommentReaction(Request $request, int $commentId): JsonResponse
|
||||
{
|
||||
// Make sure comment exists and belongs to a public artwork
|
||||
$comment = ArtworkComment::with('artwork')
|
||||
->where('id', $commentId)
|
||||
->whereHas('artwork', fn ($q) => $q->public()->published())
|
||||
->firstOrFail();
|
||||
|
||||
$slug = $this->validateReactionSlug($request);
|
||||
|
||||
return $this->toggle(
|
||||
model: new CommentReaction(),
|
||||
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
|
||||
countWhere: ['comment_id' => $commentId],
|
||||
entityId: $commentId,
|
||||
entityType: 'comment',
|
||||
userId: $request->user()->id,
|
||||
slug: $slug,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shared internals
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function toggle(
|
||||
\Illuminate\Database\Eloquent\Model $model,
|
||||
array $where,
|
||||
array $countWhere,
|
||||
int $entityId,
|
||||
string $entityType,
|
||||
int $userId,
|
||||
string $slug,
|
||||
): JsonResponse {
|
||||
$table = $model->getTable();
|
||||
$existing = DB::table($table)->where($where)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Toggle off
|
||||
DB::table($table)->where($where)->delete();
|
||||
$active = false;
|
||||
} else {
|
||||
// Toggle on
|
||||
DB::table($table)->insertOrIgnore(array_merge($where, [
|
||||
'created_at' => now(),
|
||||
]));
|
||||
$active = true;
|
||||
}
|
||||
|
||||
// Return fresh totals per reaction type
|
||||
$totals = $this->getTotals($table, $countWhere, $userId);
|
||||
|
||||
return response()->json([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'reaction' => $slug,
|
||||
'active' => $active,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse
|
||||
{
|
||||
if ($entityType === 'artwork') {
|
||||
$table = 'artwork_reactions';
|
||||
$where = ['artwork_id' => $entityId];
|
||||
} else {
|
||||
$table = 'comment_reactions';
|
||||
$where = ['comment_id' => $entityId];
|
||||
}
|
||||
|
||||
$totals = $this->getTotals($table, $where, $userId);
|
||||
|
||||
return response()->json([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return per-slug totals and whether the current user has each reaction.
|
||||
*/
|
||||
private function getTotals(string $table, array $where, ?int $userId): array
|
||||
{
|
||||
$rows = DB::table($table)
|
||||
->where($where)
|
||||
->selectRaw('reaction, COUNT(*) as total')
|
||||
->groupBy('reaction')
|
||||
->get()
|
||||
->keyBy('reaction');
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
// Check if current user has this reaction
|
||||
$mine = false;
|
||||
if ($userId && $count > 0) {
|
||||
$mine = DB::table($table)
|
||||
->where($where)
|
||||
->where('reaction', $slug)
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$totals[$slug] = [
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function validateReactionSlug(Request $request): string
|
||||
{
|
||||
$request->validate([
|
||||
'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())],
|
||||
]);
|
||||
|
||||
return $request->input('reaction');
|
||||
}
|
||||
|
||||
private function validateExists(string $table, int $id): void
|
||||
{
|
||||
if (! DB::table($table)->where('id', $id)->exists()) {
|
||||
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Api/ReportController.php
Normal file
63
app/Http/Controllers/Api/ReportController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = $request->validate([
|
||||
'target_type' => 'required|in:message,conversation,user',
|
||||
'target_id' => 'required|integer|min:1',
|
||||
'reason' => 'required|string|max:120',
|
||||
'details' => 'nullable|string|max:4000',
|
||||
]);
|
||||
|
||||
$targetType = $data['target_type'];
|
||||
$targetId = (int) $data['target_id'];
|
||||
|
||||
if ($targetType === 'message') {
|
||||
$message = Message::query()->findOrFail($targetId);
|
||||
$allowed = ConversationParticipant::query()
|
||||
->where('conversation_id', $message->conversation_id)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
abort_unless($allowed, 403, 'You are not allowed to report this message.');
|
||||
}
|
||||
|
||||
if ($targetType === 'conversation') {
|
||||
$allowed = ConversationParticipant::query()
|
||||
->where('conversation_id', $targetId)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
|
||||
}
|
||||
|
||||
if ($targetType === 'user') {
|
||||
User::query()->findOrFail($targetId);
|
||||
}
|
||||
|
||||
$report = Report::query()->create([
|
||||
'reporter_id' => $user->id,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'reason' => $data['reason'],
|
||||
'details' => $data['details'] ?? null,
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $report->id, 'status' => $report->status], 201);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Api/Search/UserSearchController.php
Normal file
59
app/Http/Controllers/Api/Search/UserSearchController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Search;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserSearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/search/users?q=gregor&per_page=4
|
||||
*
|
||||
* Public, rate-limited. Strips a leading @ from the query so that
|
||||
* typing "@gregor" and "gregor" both work.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$raw = trim((string) $request->query('q', ''));
|
||||
$q = ltrim($raw, '@');
|
||||
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['data' => []]);
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->query('per_page', 4), 8);
|
||||
|
||||
$users = User::query()
|
||||
->where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($qb) use ($q) {
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
})
|
||||
->with(['profile', 'statistics'])
|
||||
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
|
||||
->orderBy('username')
|
||||
->limit($perPage)
|
||||
->get(['id', 'username']);
|
||||
|
||||
$data = $users->map(function (User $user) {
|
||||
$username = strtolower((string) ($user->username ?? ''));
|
||||
$avatarHash = $user->profile?->avatar_hash;
|
||||
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'type' => 'user',
|
||||
'username' => $username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'profile_url' => '/@' . $username,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -5,51 +5,75 @@ namespace App\Http\Controllers\Community;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LatestCommentsController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 20;
|
||||
$page_title = 'Latest Comments';
|
||||
|
||||
$query = ArtworkComment::with(['user', 'artwork'])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->orderByDesc('created_at');
|
||||
// Build initial (first-page, type=all) data for React SSR props
|
||||
$initialData = Cache::remember('comments.latest.all.page1', 120, function () {
|
||||
return ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->paginate(self::PER_PAGE);
|
||||
});
|
||||
|
||||
$comments = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$comments->getCollection()->transform(function (ArtworkComment $c) {
|
||||
$art = $c->artwork;
|
||||
$items = $initialData->getCollection()->map(function (ArtworkComment $c) {
|
||||
$art = $c->artwork;
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url'] ?? null) : null;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
return (object) [
|
||||
'comment_id' => $c->getKey(),
|
||||
'comment_description' => $c->content,
|
||||
'commenter_id' => $c->user_id,
|
||||
'commenter_username' => $user?->username ?? null,
|
||||
'country' => $user->country ?? null,
|
||||
'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
|
||||
'uname' => $user->username ?? $user->name ?? 'User',
|
||||
'signature' => $user->signature ?? null,
|
||||
'user_type' => $user->role ?? null,
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->title ?? null,
|
||||
'picture' => $art->file_name ?? null,
|
||||
'thumb' => $thumb,
|
||||
'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(),
|
||||
return [
|
||||
'comment_id' => $c->getKey(),
|
||||
'comment_text' => e(strip_tags($c->content ?? '')),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
|
||||
'commenter' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username ?? null,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
],
|
||||
|
||||
'artwork' => $art ? [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
|
||||
'thumb' => $thumb,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Latest Comments';
|
||||
$props = [
|
||||
'initialComments' => $items->values()->all(),
|
||||
'initialMeta' => [
|
||||
'current_page' => $initialData->currentPage(),
|
||||
'last_page' => $initialData->lastPage(),
|
||||
'per_page' => $initialData->perPage(),
|
||||
'total' => $initialData->total(),
|
||||
'has_more' => $initialData->hasMorePages(),
|
||||
],
|
||||
'isAuthenticated' => (bool) auth()->user(),
|
||||
];
|
||||
|
||||
return view('web.comments.latest', compact('page_title', 'comments'));
|
||||
return view('web.comments.latest', compact('page_title', 'props'));
|
||||
}
|
||||
}
|
||||
|
||||
33
app/Http/Controllers/Dashboard/DashboardAwardsController.php
Normal file
33
app/Http/Controllers/Dashboard/DashboardAwardsController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardAwardsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereHas('awards')
|
||||
->with(['awardStat', 'stats', 'categories.contentType'])
|
||||
->orderByDesc(
|
||||
\App\Models\ArtworkAwardStat::select('score_total')
|
||||
->whereColumn('artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->paginate(24)
|
||||
->withQueryString();
|
||||
|
||||
return view('dashboard.awards', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'My Awards – SkinBase',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -17,23 +18,10 @@ class FavoriteController extends Controller
|
||||
$user = $request->user();
|
||||
$perPage = 20;
|
||||
|
||||
$favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null);
|
||||
if (! $favTable) {
|
||||
return view('dashboard.favorites', ['artworks' => new LengthAwarePaginator([], 0, $perPage)]);
|
||||
}
|
||||
|
||||
$favTable = 'artwork_favourites';
|
||||
$sort = $request->query('sort', 'newest');
|
||||
$order = $sort === 'oldest' ? 'asc' : 'desc';
|
||||
|
||||
// Determine a column to order by (legacy 'datum' or modern timestamps)
|
||||
$schema = DB::getSchemaBuilder();
|
||||
$orderColumn = null;
|
||||
foreach (['datum', 'created_at', 'created', 'date'] as $col) {
|
||||
if ($schema->hasColumn($favTable, $col)) {
|
||||
$orderColumn = $col;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$orderColumn = 'created_at';
|
||||
|
||||
$query = DB::table($favTable)->where('user_id', (int) $user->id);
|
||||
if ($orderColumn) {
|
||||
@@ -78,19 +66,18 @@ class FavoriteController extends Controller
|
||||
$artwork = (int) $last;
|
||||
}
|
||||
}
|
||||
$favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null);
|
||||
if ($favTable) {
|
||||
$artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork;
|
||||
Log::info('FavoriteController::destroy', ['favTable' => $favTable, 'user_id' => $user->id ?? null, 'artwork' => $artwork, 'artworkId' => $artworkId]);
|
||||
$deleted = DB::table($favTable)
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
$artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork;
|
||||
Log::info('FavoriteController::destroy', ['user_id' => $user->id ?? null, 'artworkId' => $artworkId]);
|
||||
// Look up creator before deleting so we can decrement their counter
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
|
||||
// Fallback: some schemas or test setups may not match user_id; try deleting by artwork_id alone
|
||||
if (! $deleted) {
|
||||
DB::table($favTable)->where('artwork_id', $artworkId)->delete();
|
||||
}
|
||||
DB::table('artwork_favourites')
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
|
||||
if ($creatorId) {
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard.favorites')->with('status', 'favourite-removed');
|
||||
|
||||
@@ -3,16 +3,46 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FollowerController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
// Minimal placeholder: real implementation should query followers table
|
||||
$followers = [];
|
||||
$user = $request->user();
|
||||
$perPage = 30;
|
||||
|
||||
return view('dashboard.followers', ['followers' => $followers]);
|
||||
// People who follow $user (user_id = $user being followed)
|
||||
$followers = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads' => $row->uploads_count ?? 0,
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return view('dashboard.followers', [
|
||||
'followers' => $followers,
|
||||
'page_title' => 'My Followers',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,48 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FollowingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
// Minimal placeholder: real implementation should query following relationships
|
||||
$following = [];
|
||||
$user = $request->user();
|
||||
$perPage = 30;
|
||||
|
||||
return view('dashboard.following', ['following' => $following]);
|
||||
// People that $user follows (follower_id = $user)
|
||||
$following = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->where('uf.follower_id', $user->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'us.followers_count',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads' => $row->uploads_count ?? 0,
|
||||
'followers_count'=> $row->followers_count ?? 0,
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return view('dashboard.following', [
|
||||
'following' => $following,
|
||||
'page_title' => 'People I Follow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Controllers/Messaging/MessagesPageController.php
Normal file
22
app/Http/Controllers/Messaging/MessagesPageController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MessagesPageController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('messages');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): View
|
||||
{
|
||||
return view('messages', [
|
||||
'activeConversationId' => $id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,17 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\UserFavorite;
|
||||
use App\Models\ArtworkFavourite;
|
||||
|
||||
class FavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request, $userId = null, $username = null)
|
||||
{
|
||||
$userId = $userId ? (int)$userId : ($request->user()->id ?? null);
|
||||
$userId = $userId ? (int) $userId : ($request->user()->id ?? null);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
@@ -23,99 +23,39 @@ class FavouritesController extends Controller
|
||||
$results = collect();
|
||||
|
||||
try {
|
||||
$schema = DB::getSchemaBuilder();
|
||||
$query = ArtworkFavourite::with(['artwork.user'])
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('artwork_id');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$favorites = $query->skip($start)->take($hits)->get();
|
||||
|
||||
$results = $favorites->map(function ($fav) {
|
||||
$art = $fav->artwork;
|
||||
if (! $art) {
|
||||
return null;
|
||||
}
|
||||
$item = (object) $art->toArray();
|
||||
$item->uname = $art->user?->username ?? $art->user?->name ?? null;
|
||||
$item->datum = $fav->created_at;
|
||||
return $item;
|
||||
})->filter();
|
||||
} catch (\Throwable $e) {
|
||||
$schema = null;
|
||||
}
|
||||
|
||||
$userIdCol = Schema::hasColumn('users', 'user_id') ? 'user_id' : 'id';
|
||||
$userNameCol = null;
|
||||
foreach (['uname', 'username', 'name'] as $col) {
|
||||
if (Schema::hasColumn('users', $col)) {
|
||||
$userNameCol = $col;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($schema && $schema->hasTable('user_favorites') && class_exists(UserFavorite::class)) {
|
||||
try {
|
||||
$query = UserFavorite::with(['artwork.user'])
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('artwork_id');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$favorites = $query->skip($start)->take($hits)->get();
|
||||
|
||||
$results = $favorites->map(function ($fav) use ($userNameCol) {
|
||||
$art = $fav->artwork;
|
||||
if (! $art) {
|
||||
return null;
|
||||
}
|
||||
$item = (object) $art->toArray();
|
||||
$item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null;
|
||||
$item->datum = $fav->created_at;
|
||||
return $item;
|
||||
})->filter();
|
||||
} catch (\Throwable $e) {
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if ($schema && $schema->hasTable('artworks_favourites')) {
|
||||
$favTable = 'artworks_favourites';
|
||||
} elseif ($schema && $schema->hasTable('favourites')) {
|
||||
$favTable = 'favourites';
|
||||
} else {
|
||||
$favTable = null;
|
||||
}
|
||||
|
||||
if ($schema && $schema->hasTable('artworks')) {
|
||||
$artTable = 'artworks';
|
||||
} elseif ($schema && $schema->hasTable('wallz')) {
|
||||
$artTable = 'wallz';
|
||||
} else {
|
||||
$artTable = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$favTable = null;
|
||||
$artTable = null;
|
||||
}
|
||||
|
||||
if ($favTable && $artTable) {
|
||||
try {
|
||||
$total = (int) DB::table($favTable)->where('user_id', $userId)->count();
|
||||
|
||||
$t2JoinCol = 't2.' . $userIdCol;
|
||||
$t2NameSelect = $userNameCol ? DB::raw("t2.{$userNameCol} as uname") : DB::raw("'' as uname");
|
||||
|
||||
$results = DB::table($favTable . ' as t1')
|
||||
->rightJoin($artTable . ' as t3', 't1.artwork_id', '=', 't3.id')
|
||||
->leftJoin('users as t2', 't3.user_id', '=', $t2JoinCol)
|
||||
->where('t1.user_id', $userId)
|
||||
->select('t3.*', $t2NameSelect, 't1.datum')
|
||||
->orderByDesc('t1.datum')
|
||||
->orderByDesc('t1.artwork_id')
|
||||
->skip($start)
|
||||
->take($hits)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
}
|
||||
}
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
}
|
||||
|
||||
$results = collect($results)->filter()->values()->transform(function ($row) {
|
||||
$row->name = $row->name ?? '';
|
||||
$row->name = $row->name ?? $row->title ?? '';
|
||||
$row->slug = $row->slug ?? Str::slug($row->name);
|
||||
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int)$row->id) : null;
|
||||
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null;
|
||||
return $row;
|
||||
});
|
||||
|
||||
$page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites';
|
||||
$displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? '');
|
||||
$page_title = $displayName . ' Favourites';
|
||||
|
||||
return view('user.favourites', [
|
||||
'results' => $results,
|
||||
@@ -134,9 +74,13 @@ class FavouritesController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('artworks_favourites') ? 'artworks_favourites' : 'favourites');
|
||||
$creatorId = (int) DB::table('artworks')->where('id', (int) $artworkId)->value('user_id');
|
||||
|
||||
DB::table($favTable)->where('user_id', (int)$userId)->where('artwork_id', (int)$artworkId)->delete();
|
||||
DB::table('artwork_favourites')->where('user_id', (int) $userId)->where('artwork_id', (int) $artworkId)->delete();
|
||||
|
||||
if ($creatorId) {
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites');
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Models\Artwork;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -29,6 +31,8 @@ class ProfileController extends Controller
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly UsernameApprovalService $usernameApprovalService,
|
||||
private readonly FollowService $followService,
|
||||
private readonly UserStatsService $userStats,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -73,35 +77,15 @@ class ProfileController extends Controller
|
||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
$actorId = (int) Auth::id();
|
||||
|
||||
$viewerId = Auth::id();
|
||||
|
||||
if ($viewerId === $target->id) {
|
||||
if ($actorId === $target->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||
}
|
||||
|
||||
$exists = DB::table('user_followers')
|
||||
->where('user_id', $target->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
DB::table('user_followers')
|
||||
->where('user_id', $target->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
DB::table('user_followers')->insertOrIgnore([
|
||||
'user_id' => $target->id,
|
||||
'follower_id'=> $viewerId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
$count = DB::table('user_followers')->where('user_id', $target->id)->count();
|
||||
$following = $this->followService->toggle($actorId, (int) $target->id);
|
||||
$count = $this->followService->followersCount((int) $target->id);
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
@@ -510,11 +494,7 @@ class ProfileController extends Controller
|
||||
// ── Increment profile views (async-safe, ignore errors) ──────────────
|
||||
if (! $isOwner) {
|
||||
try {
|
||||
DB::table('user_statistics')
|
||||
->updateOrInsert(
|
||||
['user_id' => $user->id],
|
||||
['profile_views' => DB::raw('COALESCE(profile_views, 0) + 1'), 'updated_at' => now()]
|
||||
);
|
||||
$this->userStats->incrementProfileViews($user->id);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,51 +3,87 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class TodayInHistoryController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 39;
|
||||
$perPage = 36;
|
||||
$artworks = null;
|
||||
$today = now();
|
||||
|
||||
try {
|
||||
$base = DB::table('featured_works as t0')
|
||||
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
|
||||
->join('categories as t2', 't1.category', '=', 't2.id')
|
||||
->where('t1.approved', 1)
|
||||
->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())')
|
||||
->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())')
|
||||
->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', DB::raw('t2.name as category_name'));
|
||||
// ── Strategy 1: legacy featured_works table (historical data from old site) ─
|
||||
$hasFeaturedWorks = false;
|
||||
try { $hasFeaturedWorks = Schema::hasTable('featured_works'); } catch (\Throwable) {}
|
||||
|
||||
$artworks = $base->orderBy('t0.post_date','desc')->paginate($hits);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
if ($hasFeaturedWorks) {
|
||||
try {
|
||||
$artworks = DB::table('featured_works as f')
|
||||
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
|
||||
->where('a.is_approved', true)
|
||||
->where('a.is_public', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->whereRaw('MONTH(f.post_date) = ?', [$today->month])
|
||||
->whereRaw('DAY(f.post_date) = ?', [$today->day])
|
||||
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
|
||||
DB::raw('f.post_date as featured_date'))
|
||||
->orderBy('f.post_date', 'desc')
|
||||
->paginate($perPage);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$row->encoded = \App\Services\LegacyService::encode($row->id);
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
// ── Strategy 2: new artwork_features table ───────────────────────────────
|
||||
if (!$artworks || $artworks->total() === 0) {
|
||||
try {
|
||||
$artworks = DB::table('artwork_features as f')
|
||||
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
|
||||
->where('f.is_active', true)
|
||||
->where('a.is_approved', true)
|
||||
->where('a.is_public', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->whereNotNull('a.published_at')
|
||||
->whereRaw('MONTH(f.featured_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(f.featured_at) = ?', [$today->day])
|
||||
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
|
||||
DB::raw('f.featured_at as featured_date'))
|
||||
->orderBy('f.featured_at', 'desc')
|
||||
->paginate($perPage);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrich with CDN thumbnails (batch load to avoid N+1) ─────────────────
|
||||
if ($artworks && method_exists($artworks, 'getCollection') && $artworks->count() > 0) {
|
||||
$ids = $artworks->getCollection()->pluck('id')->all();
|
||||
$modelsById = Artwork::whereIn('id', $ids)->get()->keyBy('id');
|
||||
|
||||
$artworks->getCollection()->transform(function ($row) use ($modelsById) {
|
||||
/** @var ?Artwork $art */
|
||||
$art = $modelsById->get($row->id);
|
||||
if ($art) {
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg';
|
||||
$row->art_url = '/art/' . $art->id . '/' . $art->slug;
|
||||
$row->name = $art->title ?: ($row->name ?? 'Untitled');
|
||||
} else {
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->art_url = '/art/' . $row->id;
|
||||
$row->name = $row->name ?? 'Untitled';
|
||||
}
|
||||
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
return view('legacy::today-in-history', [
|
||||
'artworks' => $artworks,
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Popular on this day in history',
|
||||
'todayLabel' => $today->format('F j'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ class TopFavouritesController extends Controller
|
||||
$hits = 21;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = DB::table('artworks_favourites as t1')
|
||||
->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id')
|
||||
->where('t2.approved', 1)
|
||||
->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num'))
|
||||
->groupBy('t1.artwork_id');
|
||||
$base = DB::table('artwork_favourites as t1')
|
||||
->join('artworks as t2', 't1.artwork_id', '=', 't2.id')
|
||||
->whereNotNull('t2.published_at')
|
||||
->select('t2.id', 't2.title as name', 't2.slug', DB::raw('NULL as picture'), DB::raw('NULL as category'), DB::raw('COUNT(*) as num'))
|
||||
->groupBy('t2.id', 't2.title', 't2.slug');
|
||||
|
||||
try {
|
||||
$paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString();
|
||||
|
||||
243
app/Http/Controllers/Web/DiscoverController.php
Normal file
243
app/Http/Controllers/Web/DiscoverController.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* DiscoverController
|
||||
*
|
||||
* Powers the /discover/* discovery pages:
|
||||
* - /discover/trending → most viewed in last 7 days
|
||||
* - /discover/fresh → latest uploads (replaces /uploads/latest)
|
||||
* - /discover/top-rated → highest favourite count
|
||||
* - /discover/most-downloaded → most downloaded all-time
|
||||
* - /discover/on-this-day → published on this calendar day in previous years
|
||||
*/
|
||||
final class DiscoverController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkSearchService $searchService,
|
||||
) {}
|
||||
|
||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverTrending($perPage);
|
||||
$artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Trending Artworks',
|
||||
'section' => 'trending',
|
||||
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
|
||||
'icon' => 'fa-fire',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/fresh ─────────────────────────────────────────────────────
|
||||
|
||||
public function fresh(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverFresh($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Fresh Uploads',
|
||||
'section' => 'fresh',
|
||||
'description' => 'The latest artworks just uploaded to Skinbase.',
|
||||
'icon' => 'fa-bolt',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/top-rated ─────────────────────────────────────────────────
|
||||
|
||||
public function topRated(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverTopRated($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Top Rated Artworks',
|
||||
'section' => 'top-rated',
|
||||
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
|
||||
'icon' => 'fa-medal',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/most-downloaded ───────────────────────────────────────────
|
||||
|
||||
public function mostDownloaded(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverMostDownloaded($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Most Downloaded',
|
||||
'section' => 'most-downloaded',
|
||||
'description' => 'All-time most downloaded artworks on Skinbase.',
|
||||
'icon' => 'fa-download',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/on-this-day ───────────────────────────────────────────────
|
||||
|
||||
public function onThisDay(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$today = now();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||
->whereRaw('YEAR(published_at) < ?', [$today->year])
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'On This Day',
|
||||
'section' => 'on-this-day',
|
||||
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
|
||||
'icon' => 'fa-calendar-day',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /creators/rising ────────────────────────────────────────────────────
|
||||
|
||||
public function risingCreators(Request $request)
|
||||
{
|
||||
$perPage = 20;
|
||||
|
||||
// Creators with artworks published in the last 90 days, ordered by total recent views.
|
||||
$hasStats = false;
|
||||
try { $hasStats = Schema::hasTable('artwork_stats'); } catch (\Throwable) {}
|
||||
|
||||
if ($hasStats) {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published')
|
||||
->groupBy('artworks.user_id');
|
||||
} else {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->where('published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published')
|
||||
->groupBy('user_id');
|
||||
}
|
||||
|
||||
$creators = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published')
|
||||
->orderByDesc('t.recent_views')
|
||||
->orderByDesc('t.latest_published')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
$creators->getCollection()->transform(function ($row) {
|
||||
return (object) [
|
||||
'user_id' => $row->user_id,
|
||||
'uname' => $row->uname,
|
||||
'username' => $row->username,
|
||||
'total' => (int) $row->recent_views,
|
||||
'metric' => 'views',
|
||||
];
|
||||
});
|
||||
|
||||
return view('web.creators.rising', [
|
||||
'creators' => $creators,
|
||||
'page_title' => 'Rising Creators — Skinbase',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/following ─────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$perPage = 24;
|
||||
|
||||
// Subquery: IDs of users this viewer follows
|
||||
$followingIds = DB::table('user_followers')
|
||||
->where('follower_id', $user->id)
|
||||
->pluck('user_id');
|
||||
|
||||
if ($followingIds->isEmpty()) {
|
||||
$artworks = Artwork::query()->paginate(0);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
'empty' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'The latest artworks from creators you follow.',
|
||||
'icon' => 'fa-user-group',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,21 @@ final class TagController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tags = \App\Models\Tag::withCount('artworks')
|
||||
->orderByDesc('artworks_count')
|
||||
->paginate(80)
|
||||
->withQueryString();
|
||||
|
||||
return view('web.tags.index', [
|
||||
'tags' => $tags,
|
||||
'page_title' => 'Browse Tags — Skinbase',
|
||||
'page_canonical' => route('tags.index'),
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Tag $tag, Request $request): View
|
||||
{
|
||||
$sort = $request->query('sort', 'popular'); // popular | latest | downloads
|
||||
|
||||
@@ -43,12 +43,10 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$isFavorited = DB::table('user_favorites')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
$isFavorited = DB::table('artwork_favourites')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
|
||||
if (Schema::hasTable('friends_list') && !empty($this->user?->id)) {
|
||||
$isFollowing = DB::table('friends_list')
|
||||
|
||||
Reference in New Issue
Block a user