feat: add community activity feed and mentions
This commit is contained in:
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\CommunityActivityService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class CommunityActivityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CommunityActivityService $activityService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$filter = $this->resolveFilter($request);
|
||||||
|
|
||||||
|
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed = $this->activityService->getFeed(
|
||||||
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: (int) $request->query('page', 1),
|
||||||
|
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFilter(Request $request): string
|
||||||
|
{
|
||||||
|
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||||
|
return 'following';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $request->query('filter', 'all');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,120 +5,50 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ActivityEvent;
|
use App\Services\CommunityActivityService;
|
||||||
use App\Models\Artwork;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Community activity feed.
|
|
||||||
*
|
|
||||||
* GET /community/activity?type=global|following
|
|
||||||
*/
|
|
||||||
final class CommunityActivityController extends Controller
|
final class CommunityActivityController extends Controller
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 30;
|
public function __construct(private readonly CommunityActivityService $activityService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$filter = $this->resolveFilter($request);
|
||||||
$type = $request->query('type', 'global'); // global | following
|
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
$perPage = self::PER_PAGE;
|
$filter = 'all';
|
||||||
|
|
||||||
$query = ActivityEvent::query()
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->with(['actor:id,name,username']);
|
|
||||||
|
|
||||||
if ($type === 'following' && $user) {
|
|
||||||
// Show only events from followed users
|
|
||||||
$followingIds = DB::table('user_followers')
|
|
||||||
->where('follower_id', $user->id)
|
|
||||||
->pluck('user_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if (empty($followingIds)) {
|
|
||||||
$query->whereRaw('0 = 1'); // empty result set
|
|
||||||
} else {
|
|
||||||
$query->whereIn('actor_id', $followingIds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$events = $query->paginate($perPage)->withQueryString();
|
$feed = $this->activityService->getFeed(
|
||||||
$enriched = $this->enrich($events->getCollection());
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: 1,
|
||||||
|
perPage: CommunityActivityService::DEFAULT_PER_PAGE,
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
return view('web.community.activity', [
|
return view('web.comments.latest', [
|
||||||
'events' => $events,
|
|
||||||
'enriched' => $enriched,
|
|
||||||
'active_tab' => $type,
|
|
||||||
'page_title' => 'Community Activity',
|
'page_title' => 'Community Activity',
|
||||||
|
'props' => [
|
||||||
|
'initialActivities' => $feed['data'],
|
||||||
|
'initialMeta' => $feed['meta'],
|
||||||
|
'initialFilter' => $feed['filter'],
|
||||||
|
'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
'isAuthenticated' => (bool) $request->user(),
|
||||||
|
],
|
||||||
|
'initialFilter' => $feed['filter'],
|
||||||
|
'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function resolveFilter(Request $request): string
|
||||||
* Attach target object data to each event for display.
|
|
||||||
*/
|
|
||||||
private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection
|
|
||||||
{
|
{
|
||||||
// Collect artwork IDs and user IDs to eager-load
|
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||||
$artworkIds = $events
|
return 'following';
|
||||||
->where('target_type', ActivityEvent::TARGET_ARTWORK)
|
}
|
||||||
->pluck('target_id')
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$userIds = $events
|
return (string) $request->query('filter', 'all');
|
||||||
->where('target_type', ActivityEvent::TARGET_USER)
|
|
||||||
->pluck('target_id')
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$artworks = Artwork::whereIn('id', $artworkIds)
|
|
||||||
->with('user:id,name,username')
|
|
||||||
->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext'])
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$users = User::whereIn('id', $userIds)
|
|
||||||
->with('profile:user_id,avatar_hash')
|
|
||||||
->get(['id', 'name', 'username'])
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
return $events->map(function (ActivityEvent $event) use ($artworks, $users): array {
|
|
||||||
$target = null;
|
|
||||||
|
|
||||||
if ($event->target_type === ActivityEvent::TARGET_ARTWORK) {
|
|
||||||
$artwork = $artworks->get($event->target_id);
|
|
||||||
$target = $artwork ? [
|
|
||||||
'id' => $artwork->id,
|
|
||||||
'title' => $artwork->title,
|
|
||||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
|
||||||
'thumb' => $artwork->thumbUrl('sm'),
|
|
||||||
] : null;
|
|
||||||
} elseif ($event->target_type === ActivityEvent::TARGET_USER) {
|
|
||||||
$u = $users->get($event->target_id);
|
|
||||||
$target = $u ? [
|
|
||||||
'id' => $u->id,
|
|
||||||
'name' => $u->name,
|
|
||||||
'username' => $u->username,
|
|
||||||
'url' => '/@' . $u->username,
|
|
||||||
] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $event->id,
|
|
||||||
'type' => $event->type,
|
|
||||||
'target_type' => $event->target_type,
|
|
||||||
'actor' => [
|
|
||||||
'id' => $event->actor?->id,
|
|
||||||
'name' => $event->actor?->name,
|
|
||||||
'username' => $event->actor?->username,
|
|
||||||
'url' => '/@' . $event->actor?->username,
|
|
||||||
],
|
|
||||||
'target' => $target,
|
|
||||||
'created_at' => $event->created_at?->toIso8601String(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
app/Models/UserMention.php
Normal file
51
app/Models/UserMention.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserMention extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_mentions';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'mentioned_user_id',
|
||||||
|
'artwork_id',
|
||||||
|
'comment_id',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'mentioned_user_id' => 'integer',
|
||||||
|
'artwork_id' => 'integer',
|
||||||
|
'comment_id' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function actor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mentionedUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'mentioned_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ArtworkComment::class, 'comment_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Observers;
|
|||||||
|
|
||||||
use App\Models\ArtworkComment;
|
use App\Models\ArtworkComment;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
|
use App\Services\UserMentionSyncService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,7 @@ class ArtworkCommentObserver
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
|
private readonly UserMentionSyncService $mentionSync,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function created(ArtworkComment $comment): void
|
public function created(ArtworkComment $comment): void
|
||||||
@@ -28,6 +30,14 @@ class ArtworkCommentObserver
|
|||||||
// The commenter is "active"
|
// The commenter is "active"
|
||||||
$this->userStats->ensureRow($comment->user_id);
|
$this->userStats->ensureRow($comment->user_id);
|
||||||
$this->userStats->setLastActiveAt($comment->user_id);
|
$this->userStats->setLastActiveAt($comment->user_id);
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
if ($comment->wasChanged(['content', 'raw_content', 'rendered_content', 'parent_id'])) {
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft delete. */
|
/** Soft delete. */
|
||||||
@@ -37,6 +47,8 @@ class ArtworkCommentObserver
|
|||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hard delete after soft delete — already decremented; nothing to do. */
|
/** Hard delete after soft delete — already decremented; nothing to do. */
|
||||||
@@ -50,6 +62,13 @@ class ArtworkCommentObserver
|
|||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restored(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function creatorId(int $artworkId): ?int
|
private function creatorId(int $artworkId): ?int
|
||||||
|
|||||||
474
app/Services/CommunityActivityService.php
Normal file
474
app/Services/CommunityActivityService.php
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\ReactionType;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\CommentReaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserMention;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use App\Support\AvatarUrl;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class CommunityActivityService
|
||||||
|
{
|
||||||
|
public const DEFAULT_PER_PAGE = 20;
|
||||||
|
|
||||||
|
private const FILTER_ALL = 'all';
|
||||||
|
private const FILTER_COMMENTS = 'comments';
|
||||||
|
private const FILTER_REPLIES = 'replies';
|
||||||
|
private const FILTER_FOLLOWING = 'following';
|
||||||
|
private const FILTER_MY = 'my';
|
||||||
|
|
||||||
|
public function getFeed(?User $viewer, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
|
||||||
|
{
|
||||||
|
$normalizedFilter = $this->normalizeFilter($filter);
|
||||||
|
$resolvedPage = max(1, $page);
|
||||||
|
$resolvedPerPage = max(1, min(50, $perPage));
|
||||||
|
|
||||||
|
$cacheKey = sprintf(
|
||||||
|
'community_activity:%s:%d:%d:%d:%d',
|
||||||
|
$normalizedFilter,
|
||||||
|
(int) ($viewer?->id ?? 0),
|
||||||
|
(int) ($actorUserId ?? 0),
|
||||||
|
$resolvedPage,
|
||||||
|
$resolvedPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, now()->addSeconds(30), function () use ($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId): array {
|
||||||
|
return $this->buildFeed($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresAuthentication(string $filter): bool
|
||||||
|
{
|
||||||
|
return in_array($this->normalizeFilter($filter), [self::FILTER_FOLLOWING, self::FILTER_MY], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeFilter(string $filter): string
|
||||||
|
{
|
||||||
|
return match (strtolower(trim($filter))) {
|
||||||
|
self::FILTER_COMMENTS => self::FILTER_COMMENTS,
|
||||||
|
self::FILTER_REPLIES => self::FILTER_REPLIES,
|
||||||
|
self::FILTER_FOLLOWING => self::FILTER_FOLLOWING,
|
||||||
|
self::FILTER_MY => self::FILTER_MY,
|
||||||
|
default => self::FILTER_ALL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array
|
||||||
|
{
|
||||||
|
$sourceLimit = max(80, $page * $perPage * 6);
|
||||||
|
$followingIds = $filter === self::FILTER_FOLLOWING && $viewer
|
||||||
|
? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
|
||||||
|
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
|
||||||
|
$reactionModels = $this->fetchReactionModels($sourceLimit);
|
||||||
|
|
||||||
|
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
|
||||||
|
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
|
||||||
|
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
|
||||||
|
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
|
||||||
|
|
||||||
|
$merged = $commentActivities
|
||||||
|
->concat($replyActivities)
|
||||||
|
->concat($reactionActivities)
|
||||||
|
->concat($mentionActivities)
|
||||||
|
->filter(function (array $activity) use ($filter, $viewer, $followingIds, $actorUserId): bool {
|
||||||
|
$actorId = (int) ($activity['user']['id'] ?? 0);
|
||||||
|
$mentionedUserId = (int) ($activity['mentioned_user']['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($actorUserId !== null && $actorId !== (int) $actorUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($filter) {
|
||||||
|
self::FILTER_COMMENTS => $activity['type'] === 'comment',
|
||||||
|
self::FILTER_REPLIES => $activity['type'] === 'reply',
|
||||||
|
self::FILTER_FOLLOWING => in_array($actorId, $followingIds, true),
|
||||||
|
self::FILTER_MY => $viewer !== null
|
||||||
|
&& ($actorId === (int) $viewer->id || ($activity['type'] === 'mention' && $mentionedUserId === (int) $viewer->id)),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
->sortByDesc(fn (array $activity) => $activity['sort_timestamp'] ?? $activity['created_at'])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$total = $merged->count();
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pageItems = $merged->slice($offset, $perPage)->values();
|
||||||
|
$reactionTotals = $this->loadCommentReactionTotals(
|
||||||
|
$pageItems->pluck('comment.id')->filter()->map(fn ($id) => (int) $id)->unique()->all(),
|
||||||
|
$viewer?->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $pageItems->map(function (array $activity) use ($reactionTotals): array {
|
||||||
|
$commentId = (int) ($activity['comment']['id'] ?? 0);
|
||||||
|
if ($commentId > 0) {
|
||||||
|
$activity['comment']['reactions'] = $reactionTotals[$commentId] ?? $this->defaultReactionTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($activity['sort_timestamp']);
|
||||||
|
|
||||||
|
return $activity;
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'last_page' => (int) max(1, ceil($total / $perPage)),
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'has_more' => $offset + $perPage < $total,
|
||||||
|
],
|
||||||
|
'filter' => $filter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
|
||||||
|
{
|
||||||
|
return ArtworkComment::query()
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'artwork_id',
|
||||||
|
'user_id',
|
||||||
|
'parent_id',
|
||||||
|
'content',
|
||||||
|
'raw_content',
|
||||||
|
'rendered_content',
|
||||||
|
'created_at',
|
||||||
|
'is_approved',
|
||||||
|
])
|
||||||
|
->with([
|
||||||
|
'user' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($query) {
|
||||||
|
$query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->when($repliesOnly, fn ($query) => $query->whereNotNull('parent_id'), fn ($query) => $query->whereNull('parent_id'))
|
||||||
|
->whereHas('user', function ($query) {
|
||||||
|
$query->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('artwork', function ($query) {
|
||||||
|
$query->public()->published()->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchReactionModels(int $limit): Collection
|
||||||
|
{
|
||||||
|
return CommentReaction::query()
|
||||||
|
->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at'])
|
||||||
|
->with([
|
||||||
|
'user' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'comment' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
|
||||||
|
->with([
|
||||||
|
'user' => function ($userQuery) {
|
||||||
|
$userQuery
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($artworkQuery) {
|
||||||
|
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->whereHas('user', function ($query) {
|
||||||
|
$query->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('comment', function ($query) {
|
||||||
|
$query
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereHas('user', function ($userQuery) {
|
||||||
|
$userQuery->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('artwork', function ($artworkQuery) {
|
||||||
|
$artworkQuery->public()->published()->whereNull('deleted_at');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapCommentActivity(ArtworkComment $comment, string $type): array
|
||||||
|
{
|
||||||
|
$artwork = $comment->artwork;
|
||||||
|
$iso = $comment->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $type . ':' . $comment->id,
|
||||||
|
'type' => $type,
|
||||||
|
'user' => $this->buildUserPayload($comment->user),
|
||||||
|
'comment' => $this->buildCommentPayload($comment),
|
||||||
|
'artwork' => $this->buildArtworkPayload($artwork),
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapReactionActivity(CommentReaction $reaction): array
|
||||||
|
{
|
||||||
|
$comment = $reaction->comment;
|
||||||
|
$artwork = $comment?->artwork;
|
||||||
|
$reactionType = ReactionType::tryFrom((string) $reaction->reaction);
|
||||||
|
$iso = $reaction->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'reaction:' . $reaction->id,
|
||||||
|
'type' => 'reaction',
|
||||||
|
'user' => $this->buildUserPayload($reaction->user),
|
||||||
|
'comment' => $comment ? $this->buildCommentPayload($comment) : null,
|
||||||
|
'artwork' => $this->buildArtworkPayload($artwork),
|
||||||
|
'reaction' => [
|
||||||
|
'slug' => $reactionType?->value ?? (string) $reaction->reaction,
|
||||||
|
'emoji' => $reactionType?->emoji() ?? '👍',
|
||||||
|
'label' => $reactionType?->label() ?? Str::headline((string) $reaction->reaction),
|
||||||
|
],
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $reaction->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchMentionActivities(int $limit): Collection
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserMention::query()
|
||||||
|
->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at'])
|
||||||
|
->with([
|
||||||
|
'actor' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'mentionedUser' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'comment' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
|
||||||
|
->with([
|
||||||
|
'user' => function ($userQuery) {
|
||||||
|
$userQuery
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($artworkQuery) {
|
||||||
|
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||||
|
->whereHas('mentionedUser', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||||
|
->whereHas('comment', function ($query) {
|
||||||
|
$query
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereHas('artwork', fn ($artworkQuery) => $artworkQuery->public()->published()->whereNull('deleted_at'));
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function (UserMention $mention): array {
|
||||||
|
$iso = $mention->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'mention:' . $mention->id,
|
||||||
|
'type' => 'mention',
|
||||||
|
'user' => $this->buildUserPayload($mention->actor),
|
||||||
|
'mentioned_user' => $this->buildUserPayload($mention->mentionedUser),
|
||||||
|
'comment' => $mention->comment ? $this->buildCommentPayload($mention->comment) : null,
|
||||||
|
'artwork' => $this->buildArtworkPayload($mention->comment?->artwork),
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $mention->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUserPayload(?User $user): ?array
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string) ($user->username ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $user->id,
|
||||||
|
'name' => html_entity_decode((string) ($user->name ?? $username ?: 'User'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'username' => $username,
|
||||||
|
'profile_url' => $username !== '' ? '/@' . $username : null,
|
||||||
|
'avatar_url' => $user->profile?->avatar_url ?: AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
|
||||||
|
'badge' => $this->resolveBadge($user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBadge(User $user): ?array
|
||||||
|
{
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
return ['label' => 'Admin', 'tone' => 'rose'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isModerator()) {
|
||||||
|
return ['label' => 'Moderator', 'tone' => 'amber'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($user->artworks_count ?? 0) > 0) {
|
||||||
|
return ['label' => 'Creator', 'tone' => 'sky'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildArtworkPayload(?Artwork $artwork): ?array
|
||||||
|
{
|
||||||
|
if (! $artwork) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||||
|
if ($slug === '') {
|
||||||
|
$slug = (string) $artwork->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumb = ThumbnailPresenter::present($artwork, 'md');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $artwork->id,
|
||||||
|
'title' => html_entity_decode((string) ($artwork->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||||
|
'thumb' => $thumb['url'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCommentPayload(ArtworkComment $comment): array
|
||||||
|
{
|
||||||
|
$artwork = $this->buildArtworkPayload($comment->artwork);
|
||||||
|
$commentUrl = $artwork ? $artwork['url'] . '#comment-' . $comment->id : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $comment->id,
|
||||||
|
'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
|
||||||
|
'body' => $this->excerptComment($comment),
|
||||||
|
'body_html' => $comment->getDisplayHtml(),
|
||||||
|
'url' => $commentUrl,
|
||||||
|
'author' => $this->buildUserPayload($comment->user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function excerptComment(ArtworkComment $comment): string
|
||||||
|
{
|
||||||
|
$raw = $comment->raw_content ?? $comment->content ?? '';
|
||||||
|
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode((string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?? '');
|
||||||
|
|
||||||
|
return Str::limit($plain, 180, '…');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCommentReactionTotals(array $commentIds, ?int $viewerId): array
|
||||||
|
{
|
||||||
|
if ($commentIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('comment_reactions')
|
||||||
|
->whereIn('comment_id', $commentIds)
|
||||||
|
->selectRaw('comment_id, reaction, COUNT(*) as total')
|
||||||
|
->groupBy('comment_id', 'reaction')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$viewerReactions = [];
|
||||||
|
if ($viewerId) {
|
||||||
|
$viewerReactions = DB::table('comment_reactions')
|
||||||
|
->whereIn('comment_id', $commentIds)
|
||||||
|
->where('user_id', $viewerId)
|
||||||
|
->get(['comment_id', 'reaction'])
|
||||||
|
->groupBy('comment_id')
|
||||||
|
->map(fn (Collection $items) => $items->pluck('reaction')->all())
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalsByComment = [];
|
||||||
|
foreach ($commentIds as $commentId) {
|
||||||
|
$totalsByComment[(int) $commentId] = $this->defaultReactionTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$commentId = (int) $row->comment_id;
|
||||||
|
$slug = (string) $row->reaction;
|
||||||
|
if (! isset($totalsByComment[$commentId][$slug])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalsByComment[$commentId][$slug]['count'] = (int) $row->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($viewerReactions as $commentId => $slugs) {
|
||||||
|
foreach ($slugs as $slug) {
|
||||||
|
if (isset($totalsByComment[(int) $commentId][(string) $slug])) {
|
||||||
|
$totalsByComment[(int) $commentId][(string) $slug]['mine'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalsByComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultReactionTotals(): array
|
||||||
|
{
|
||||||
|
$totals = [];
|
||||||
|
|
||||||
|
foreach (ReactionType::cases() as $type) {
|
||||||
|
$totals[$type->value] = [
|
||||||
|
'emoji' => $type->emoji(),
|
||||||
|
'label' => $type->label(),
|
||||||
|
'count' => 0,
|
||||||
|
'mine' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Services/UserMentionSyncService.php
Normal file
78
app/Services/UserMentionSyncService.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class UserMentionSyncService
|
||||||
|
{
|
||||||
|
public function syncForComment(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usernames = $this->extractMentions((string) ($comment->raw_content ?? $comment->content ?? ''));
|
||||||
|
$mentionedIds = $usernames === []
|
||||||
|
? []
|
||||||
|
: User::query()
|
||||||
|
->whereIn(DB::raw('LOWER(username)'), $usernames)
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($comment, $mentionedIds): void {
|
||||||
|
DB::table('user_mentions')
|
||||||
|
->where('comment_id', (int) $comment->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
if ($mentionedIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = collect($mentionedIds)
|
||||||
|
->reject(fn (int $id) => $id === (int) $comment->user_id)
|
||||||
|
->unique()
|
||||||
|
->map(fn (int $mentionedUserId) => [
|
||||||
|
'user_id' => (int) $comment->user_id,
|
||||||
|
'mentioned_user_id' => $mentionedUserId,
|
||||||
|
'artwork_id' => (int) $comment->artwork_id,
|
||||||
|
'comment_id' => (int) $comment->id,
|
||||||
|
'created_at' => $comment->created_at ?? now(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
DB::table('user_mentions')->insert($rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteForComment(int $commentId): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('user_mentions')->where('comment_id', $commentId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMentions(string $content): array
|
||||||
|
{
|
||||||
|
preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches);
|
||||||
|
|
||||||
|
return collect($matches[2] ?? [])
|
||||||
|
->map(fn ($username) => strtolower((string) $username))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_mentions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->unsignedBigInteger('mentioned_user_id');
|
||||||
|
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('comment_id');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->unique(['comment_id', 'mentioned_user_id'], 'user_mentions_comment_user_unique');
|
||||||
|
$table->index(['mentioned_user_id', 'created_at'], 'user_mentions_mentioned_created_idx');
|
||||||
|
$table->index(['user_id', 'created_at'], 'user_mentions_actor_created_idx');
|
||||||
|
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||||
|
$table->foreign('mentioned_user_id')->references('id')->on('users')->onDelete('cascade');
|
||||||
|
$table->foreign('artwork_id')->references('id')->on('artworks')->onDelete('cascade');
|
||||||
|
$table->foreign('comment_id')->references('id')->on('artwork_comments')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_mentions');
|
||||||
|
}
|
||||||
|
};
|
||||||
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import ActivityFeed from '../../components/community/ActivityFeed'
|
||||||
|
|
||||||
|
const FILTER_TABS = [
|
||||||
|
{ key: 'all', label: 'All Activity' },
|
||||||
|
{ key: 'comments', label: 'Comments' },
|
||||||
|
{ key: 'replies', label: 'Replies' },
|
||||||
|
{ key: 'following', label: 'Following', authRequired: true },
|
||||||
|
{ key: 'my', label: 'My Activity', authRequired: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function FilterPills({ activeFilter, isAuthenticated, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{FILTER_TABS.map((tab) => {
|
||||||
|
const disabled = tab.authRequired && !isAuthenticated
|
||||||
|
const active = activeFilter === tab.key
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && onChange(tab.key)}
|
||||||
|
className={[
|
||||||
|
'rounded-full border px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
active
|
||||||
|
? 'border-sky-400/30 bg-sky-500/14 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.08)]'
|
||||||
|
: 'border-white/[0.06] bg-white/[0.03] text-white/55 hover:border-white/15 hover:bg-white/[0.05] hover:text-white/85',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-35' : '',
|
||||||
|
].join(' ')}
|
||||||
|
title={disabled ? 'Log in to use this filter' : undefined}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl(filter, userId) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
|
||||||
|
if (filter && filter !== 'all') url.searchParams.set('filter', filter)
|
||||||
|
else url.searchParams.delete('filter')
|
||||||
|
|
||||||
|
if (userId) url.searchParams.set('user_id', String(userId))
|
||||||
|
else url.searchParams.delete('user_id')
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderSummary(filter, userId) {
|
||||||
|
const filterLabels = {
|
||||||
|
all: 'All Activity',
|
||||||
|
comments: 'Comments',
|
||||||
|
replies: 'Replies',
|
||||||
|
following: 'Following',
|
||||||
|
my: 'My Activity',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterNode = document.getElementById('community-activity-filter-summary')
|
||||||
|
const scopeNode = document.getElementById('community-activity-scope-summary')
|
||||||
|
|
||||||
|
if (filterNode) {
|
||||||
|
filterNode.innerHTML = `<i class="fa-solid fa-filter"></i> ${filterLabels[filter] || filterLabels.all}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeNode) {
|
||||||
|
if (userId) {
|
||||||
|
scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65'
|
||||||
|
scopeNode.innerHTML = `<i class="fa-solid fa-user"></i> User #${userId}`
|
||||||
|
} else {
|
||||||
|
scopeNode.className = 'hidden'
|
||||||
|
scopeNode.innerHTML = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommunityActivityPage({
|
||||||
|
initialActivities = [],
|
||||||
|
initialMeta = {},
|
||||||
|
initialFilter = 'all',
|
||||||
|
initialUserId = null,
|
||||||
|
isAuthenticated = false,
|
||||||
|
}) {
|
||||||
|
const [activeFilter, setActiveFilter] = useState(initialFilter)
|
||||||
|
const [activities, setActivities] = useState(initialActivities)
|
||||||
|
const [meta, setMeta] = useState(initialMeta)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const sentinelRef = useRef(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const hasMore = Boolean(meta?.has_more)
|
||||||
|
const nextPage = Number(meta?.current_page || 1) + 1
|
||||||
|
|
||||||
|
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
setError(null)
|
||||||
|
if (append) setLoadingMore(true)
|
||||||
|
else setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ filter, page: String(page) })
|
||||||
|
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||||
|
|
||||||
|
const response = await fetch(`/api/community/activity?${params.toString()}`, {
|
||||||
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
setError('Please log in to view this activity filter.')
|
||||||
|
if (!append) {
|
||||||
|
setActivities([])
|
||||||
|
setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load community activity.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || []))
|
||||||
|
setMeta(payload.meta || {})
|
||||||
|
} catch {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setError('Failed to load community activity. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialUserId])
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((nextFilter) => {
|
||||||
|
if (nextFilter === activeFilter) return
|
||||||
|
setActiveFilter(nextFilter)
|
||||||
|
updateUrl(nextFilter, initialUserId)
|
||||||
|
fetchFeed({ filter: nextFilter, page: 1, append: false })
|
||||||
|
}, [activeFilter, fetchFeed, initialUserId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateHeaderSummary(activeFilter, initialUserId)
|
||||||
|
}, [activeFilter, initialUserId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel || loading || loadingMore || !hasMore) return undefined
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||||
|
}
|
||||||
|
}, { rootMargin: '220px 0px' })
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||||
|
|
||||||
|
const resultsLabel = useMemo(() => {
|
||||||
|
const total = Number(meta?.total || activities.length || 0)
|
||||||
|
if (!total) return 'No recent activity'
|
||||||
|
return `${total.toLocaleString()} events`
|
||||||
|
}, [activities.length, meta?.total])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Live community pulse</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
|
||||||
|
Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-white/45">{resultsLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterPills activeFilter={activeFilter} isAuthenticated={isAuthenticated} onChange={handleFilterChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityFeed
|
||||||
|
activities={activities}
|
||||||
|
isLoggedIn={isAuthenticated}
|
||||||
|
loading={loading}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
error={error}
|
||||||
|
sentinelRef={sentinelRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountEl = document.getElementById('community-activity-root')
|
||||||
|
|
||||||
|
if (mountEl) {
|
||||||
|
let props = {}
|
||||||
|
try {
|
||||||
|
const propsEl = document.getElementById('community-activity-props')
|
||||||
|
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||||
|
} catch {
|
||||||
|
props = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommunityActivityPage
|
||||||
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ActivityArtworkPreview({ artwork }) {
|
||||||
|
if (!artwork?.url || !artwork?.thumb) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={artwork.url}
|
||||||
|
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
|
||||||
|
>
|
||||||
|
<div className="aspect-[6/5] overflow-hidden bg-black/20">
|
||||||
|
<img
|
||||||
|
src={artwork.thumb}
|
||||||
|
alt={artwork.title || 'Artwork'}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||||
|
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||||
|
|
||||||
|
const BADGE_TONES = {
|
||||||
|
rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200',
|
||||||
|
amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200',
|
||||||
|
sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityAvatar({ user }) {
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<a href={user.profile_url || '#'} className="shrink-0">
|
||||||
|
<img
|
||||||
|
src={user.avatar_url || FALLBACK_AVATAR}
|
||||||
|
alt={user.name || user.username || 'User'}
|
||||||
|
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.src = FALLBACK_AVATAR
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<a href={user.profile_url || '#'} className="truncate text-sm font-semibold text-white hover:text-sky-200 transition-colors">
|
||||||
|
{user.name || user.username || 'User'}
|
||||||
|
</a>
|
||||||
|
{user.username && <p className="truncate text-xs text-white/35">@{user.username}</p>}
|
||||||
|
{user.badge && (
|
||||||
|
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${badgeClassName}`}>
|
||||||
|
{user.badge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
resources/js/components/community/ActivityCard.jsx
Normal file
88
resources/js/components/community/ActivityCard.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ActivityAvatar from './ActivityAvatar'
|
||||||
|
import ActivityArtworkPreview from './ActivityArtworkPreview'
|
||||||
|
import ActivityReactions from './ActivityReactions'
|
||||||
|
|
||||||
|
function ActivityHeadline({ activity }) {
|
||||||
|
const artworkLink = activity?.artwork?.url
|
||||||
|
const artworkTitle = activity?.artwork?.title || 'an artwork'
|
||||||
|
const mentionedUser = activity?.mentioned_user
|
||||||
|
const reaction = activity?.reaction
|
||||||
|
const commentAuthor = activity?.comment?.author
|
||||||
|
|
||||||
|
switch (activity?.type) {
|
||||||
|
case 'comment':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">commented on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'reply':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">replied on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'reaction':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} </span>
|
||||||
|
<span>to </span>
|
||||||
|
{commentAuthor?.profile_url ? <a href={commentAuthor.profile_url} className="text-sky-300 hover:text-sky-200">{commentAuthor.name || commentAuthor.username || 'a creator'}</a> : <span className="text-white">a creator</span>}
|
||||||
|
<span> on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'mention':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">mentioned </span>
|
||||||
|
{mentionedUser?.profile_url ? <a href={mentionedUser.profile_url} className="text-sky-300 hover:text-sky-200">@{mentionedUser.username || mentionedUser.name}</a> : <span className="text-white">someone</span>}
|
||||||
|
<span> on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return <p className="text-sm leading-6 text-white/70">Shared new activity.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityCard({ activity, isLoggedIn = false }) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div className="sm:w-[220px] sm:shrink-0">
|
||||||
|
<ActivityAvatar user={activity.user} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ActivityHeadline activity={activity} />
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-white/25">{activity.time_ago || ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.comment?.body ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-3">
|
||||||
|
<p className="whitespace-pre-line break-words text-sm leading-6 text-white/80">{activity.comment.body}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activity.type === 'mention' && activity.mentioned_user ? (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">
|
||||||
|
<i className="fa-solid fa-at" />
|
||||||
|
Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ActivityReactions activity={activity} isLoggedIn={isLoggedIn} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:ml-auto">
|
||||||
|
<ActivityArtworkPreview artwork={activity.artwork} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
resources/js/components/community/ActivityFeed.jsx
Normal file
80
resources/js/components/community/ActivityFeed.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ActivityCard from './ActivityCard'
|
||||||
|
|
||||||
|
function ActivitySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
|
<div className="flex items-start gap-3 sm:w-[220px]">
|
||||||
|
<div className="h-11 w-11 rounded-2xl bg-white/[0.08]" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3 w-24 rounded bg-white/[0.08]" />
|
||||||
|
<div className="h-2.5 w-16 rounded bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="h-3 w-4/5 rounded bg-white/[0.08]" />
|
||||||
|
<div className="rounded-2xl border border-white/[0.04] bg-white/[0.02] px-4 py-3">
|
||||||
|
<div className="h-3 w-full rounded bg-white/[0.06]" />
|
||||||
|
<div className="mt-2 h-3 w-3/4 rounded bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-48 rounded-full bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-[132px] w-full rounded-2xl bg-white/[0.05] sm:w-[120px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ isFiltered }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-6 py-16 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.06] bg-white/[0.03] text-white/35">
|
||||||
|
<i className="fa-solid fa-wave-square text-xl" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white/80">No activity yet</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-white/45">
|
||||||
|
{isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityFeed({
|
||||||
|
activities = [],
|
||||||
|
isLoggedIn = false,
|
||||||
|
loading = false,
|
||||||
|
loadingMore = false,
|
||||||
|
error = null,
|
||||||
|
sentinelRef,
|
||||||
|
}) {
|
||||||
|
if (loading && activities.length === 0) {
|
||||||
|
return <ActivitySkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && activities.length === 0) {
|
||||||
|
return <EmptyState isFiltered={Boolean(error) === false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<ActivityCard key={activity.id} activity={activity} isLoggedIn={isLoggedIn} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loadingMore ? <ActivitySkeleton /> : null}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} className="h-6" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
resources/js/components/community/ActivityReactions.jsx
Normal file
39
resources/js/components/community/ActivityReactions.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactionBar from '../comments/ReactionBar'
|
||||||
|
|
||||||
|
export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||||
|
const commentId = activity?.comment?.id || null
|
||||||
|
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
|
||||||
|
const artworkUrl = activity?.artwork?.url || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
{commentId ? (
|
||||||
|
<ReactionBar
|
||||||
|
entityType="comment"
|
||||||
|
entityId={commentId}
|
||||||
|
initialTotals={activity?.comment?.reactions || {}}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={commentUrl}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
<i className="fa-regular fa-comment-dots" />
|
||||||
|
Reply
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{artworkUrl ? (
|
||||||
|
<a
|
||||||
|
href={artworkUrl}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
|
||||||
|
>
|
||||||
|
<i className="fa-regular fa-image" />
|
||||||
|
View artwork
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,24 +1,64 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title ?? 'Community Activity', 'url' => route('community.activity')],
|
||||||
|
]);
|
||||||
|
|
||||||
{{-- Inline props for the React component --}}
|
$initialFilterLabel = match (($initialFilter ?? 'all')) {
|
||||||
<script id="latest-comments-props" type="application/json">
|
'comments' => 'Comments',
|
||||||
|
'replies' => 'Replies',
|
||||||
|
'following' => 'Following',
|
||||||
|
'my' => 'My Activity',
|
||||||
|
default => 'All Activity',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<x-nova-page-header
|
||||||
|
section="Community"
|
||||||
|
:title="$page_title ?? 'Community Activity'"
|
||||||
|
icon="fa-wave-square"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
description="Track comments, replies, reactions, and mentions from across the Skinbase community in one live feed."
|
||||||
|
headerClass="pb-6"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||||
|
<span id="community-activity-filter-summary" class="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-sky-200">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
{{ $initialFilterLabel }}
|
||||||
|
</span>
|
||||||
|
@if (!empty($initialUserId))
|
||||||
|
<span id="community-activity-scope-summary" class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
User #{{ $initialUserId }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span id="community-activity-scope-summary" class="hidden"></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
|
<script id="community-activity-props" type="application/json">
|
||||||
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="latest-comments-root" class="min-h-screen">
|
<div id="community-activity-root" class="min-h-[480px]">
|
||||||
{{-- SSR skeleton replaced on React hydration --}}
|
<div class="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||||
<div class="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
<div class="mb-6 rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
|
||||||
<div class="mb-8">
|
<div class="h-3 w-40 animate-pulse rounded bg-white/[0.08]"></div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
<div class="mt-3 h-3 w-2/3 animate-pulse rounded bg-white/[0.06]"></div>
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title }}</h1>
|
<div class="mt-5 flex gap-2">
|
||||||
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
<div class="h-10 w-28 animate-pulse rounded-full bg-white/[0.06]"></div>
|
||||||
|
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||||
|
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-nova-500 border-t-transparent mx-auto mt-20"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@vite(['resources/js/Pages/Community/LatestCommentsPage.jsx'])
|
@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx'])
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
|||||||
'resources/js/Pages/ArtworkPage.jsx',
|
'resources/js/Pages/ArtworkPage.jsx',
|
||||||
'resources/js/Pages/Home/HomePage.jsx',
|
'resources/js/Pages/Home/HomePage.jsx',
|
||||||
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
||||||
|
'resources/js/Pages/Community/CommunityActivityPage.jsx',
|
||||||
'resources/js/Pages/Messages/Index.jsx',
|
'resources/js/Pages/Messages/Index.jsx',
|
||||||
'resources/js/profile.jsx',
|
'resources/js/profile.jsx',
|
||||||
'resources/js/feed.jsx',
|
'resources/js/feed.jsx',
|
||||||
@@ -62,4 +63,4 @@ export default defineConfig({
|
|||||||
setupFiles: ['resources/js/test/setupTests.js'],
|
setupFiles: ['resources/js/test/setupTests.js'],
|
||||||
include: ['resources/js/**/*.test.{js,jsx}'],
|
include: ['resources/js/**/*.test.{js,jsx}'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user