This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Services\LegacySmileyMapper;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -113,6 +116,7 @@ class ArtworkCommentController extends Controller
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
// Record activity event (fire-and-forget; never break the response)
try {
@@ -204,6 +208,8 @@ class ArtworkCommentController extends Controller
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
];
@@ -217,4 +223,48 @@ class ArtworkCommentController extends Controller
return $data;
}
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
{
$notifiedUserIds = [];
$creatorId = (int) ($artwork->user_id ?? 0);
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
$creator = User::query()->find($creatorId);
if ($creator) {
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parentId) {
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parentUserId);
if ($parentUser) {
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
}
User::query()
->whereIn(
'id',
UserMention::query()
->where('comment_id', (int) $comment->id)
->pluck('mentioned_user_id')
->map(fn ($id) => (int) $id)
->unique()
->all()
)
->get()
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
if ((int) $mentionedUser->id === (int) $actor->id) {
return;
}
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
});
}
}

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Events\Achievements\AchievementCheckRequested;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -14,11 +18,25 @@ use Illuminate\Support\Facades\Schema;
final class ArtworkInteractionController extends Controller
{
public function bookmark(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
request: $request,
table: 'artwork_bookmarks',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_bookmarks'
);
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function favorite(Request $request, int $artworkId): JsonResponse
{
$state = $request->boolean('state', true);
$this->toggleSimple(
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_favourites',
keyColumns: ['user_id', 'artwork_id'],
@@ -33,7 +51,7 @@ final class ArtworkInteractionController extends Controller
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state) {
if ($state && $changed) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
@@ -46,7 +64,7 @@ final class ArtworkInteractionController extends Controller
targetId: $artworkId,
);
} catch (\Throwable) {}
} else {
} elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId);
}
}
@@ -56,7 +74,7 @@ final class ArtworkInteractionController extends Controller
public function like(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'],
@@ -67,6 +85,20 @@ final class ArtworkInteractionController extends Controller
$this->syncArtworkStats($artworkId);
if ($request->boolean('state', true) && $changed) {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
$actorId = (int) $request->user()->id;
if ($creatorId > 0 && $creatorId !== $actorId) {
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
$creator = \App\Models\User::query()->find($creatorId);
$artwork = Artwork::query()->find($artworkId);
if ($creator && $artwork) {
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
}
event(new AchievementCheckRequested($creatorId));
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
@@ -104,8 +136,10 @@ final class ArtworkInteractionController extends Controller
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->boolean('state', true);
$svc = app(FollowService::class);
$state = $request->has('state')
? $request->boolean('state')
: ! $request->isMethod('delete');
if ($state) {
$svc->follow($actorId, $userId);
@@ -148,7 +182,7 @@ final class ArtworkInteractionController extends Controller
array $keyValues,
array $insertPayload,
string $requiredTable
): void {
): bool {
if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable');
}
@@ -163,10 +197,13 @@ final class ArtworkInteractionController extends Controller
if ($state) {
if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
return true;
}
} else {
$query->delete();
return $query->delete() > 0;
}
return false;
}
private function syncArtworkStats(int $artworkId): void
@@ -194,6 +231,10 @@ final class ArtworkInteractionController extends Controller
private function statusPayload(int $viewerId, int $artworkId): array
{
$isBookmarked = Schema::hasTable('artwork_bookmarks')
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isFavorited = Schema::hasTable('artwork_favourites')
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
@@ -206,15 +247,21 @@ final class ArtworkInteractionController extends Controller
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$bookmarks = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
return [
'ok' => true,
'is_bookmarked' => $isBookmarked,
'is_favorited' => $isFavorited,
'is_liked' => $isLiked,
'stats' => [
'bookmarks' => $bookmarks,
'favorites' => $favorites,
'likes' => $likes,
],

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
*/
final class ArtworkViewController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly XPService $xp,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
@@ -52,6 +56,16 @@ final class ArtworkViewController extends Controller
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
$viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
$this->xp->awardArtworkViewReceived(
(int) $artwork->user_id,
(int) $artwork->id,
$viewerId,
(string) $request->ip(),
);
}
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);

View File

@@ -36,6 +36,10 @@ final class CommunityActivityController extends Controller
private function resolveFilter(Request $request): string
{
if ($request->filled('type') && ! $request->filled('filter')) {
return (string) $request->query('type', 'all');
}
if ($request->boolean('following') && ! $request->filled('filter')) {
return 'following';
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Leaderboard;
use App\Services\LeaderboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class LeaderboardController extends Controller
{
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
);
}
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
);
}
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
);
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Services\Posts\NotificationDigestService;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
@@ -14,48 +14,24 @@ use App\Http\Controllers\Controller;
*/
class NotificationController extends Controller
{
public function __construct(private NotificationDigestService $digest) {}
public function __construct(private NotificationService $notifications) {}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->query('page', 1));
$notifications = $user->notifications()
->latest()
->limit(200) // aggregate from last 200 raw notifs
->get();
$digested = $this->digest->aggregate($notifications);
// Simple manual pagination on the digested array
$perPage = 20;
$total = count($digested);
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
$unread = $user->unreadNotifications()->count();
return response()->json([
'data' => array_values($sliced),
'unread_count' => $unread,
'meta' => [
'total' => $total,
'current_page' => $page,
'last_page' => (int) ceil($total / $perPage) ?: 1,
'per_page' => $perPage,
],
]);
return response()->json(
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
);
}
public function readAll(Request $request): JsonResponse
{
$request->user()->unreadNotifications()->update(['read_at' => now()]);
$this->notifications->markAllRead($request->user());
return response()->json(['message' => 'All notifications marked as read.']);
}
public function markRead(Request $request, string $id): JsonResponse
{
$notif = $request->user()->notifications()->findOrFail($id);
$notif->markAsRead();
$this->notifications->markRead($request->user(), $id);
return response()->json(['message' => 'Notification marked as read.']);
}
}

View File

@@ -116,6 +116,8 @@ class PostCommentController extends Controller
'username' => $comment->user->username,
'name' => $comment->user->name,
'avatar' => $comment->user->profile?->avatar_url ?? null,
'level' => (int) ($comment->user->level ?? 1),
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
],
];
}

View File

@@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -57,20 +56,9 @@ final class ProfileApiController extends Controller
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
$data = collect($paginator->items())->map(function (Artwork $art) {
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'published_at' => $art->published_at,
];
})->values();
$data = collect($paginator->items())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
->values();
return response()->json([
'data' => $data,
@@ -85,7 +73,8 @@ final class ProfileApiController extends Controller
*/
public function favourites(Request $request, string $username): JsonResponse
{
if (! Schema::hasTable('user_favorites')) {
$favouriteTable = $this->resolveFavouriteTable();
if ($favouriteTable === null) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
@@ -95,16 +84,18 @@ final class ProfileApiController extends Controller
}
$perPage = 24;
$cursor = $request->input('cursor');
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table('user_favorites as uf')
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
->where('uf.user_id', $user->id)
$favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->orderByDesc('uf.created_at')
->offset($cursor ? (int) base64_decode($cursor) : 0)
->whereNotNull('a.published_at')
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
->limit($perPage + 1)
->pluck('a.id');
@@ -120,24 +111,14 @@ final class ProfileApiController extends Controller
->get()
->keyBy('id');
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
$art = $indexed[$id];
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
];
})->values();
$data = $favIds
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
->values();
return response()->json([
'data' => $data,
'next_cursor' => null, // Simple offset pagination for now
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
'has_more' => $hasMore,
]);
}
@@ -174,4 +155,48 @@ final class ProfileApiController extends Controller
$normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
}
private function resolveFavouriteTable(): ?string
{
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
if (Schema::hasTable($table)) {
return $table;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCardPayload(Artwork $art): array
{
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'published_at' => $this->formatIsoDate($art->published_at),
];
}
private function formatIsoDate(mixed $value): ?string
{
if ($value instanceof CarbonInterface) {
return $value->toISOString();
}
if ($value instanceof \DateTimeInterface) {
return $value->format(DATE_ATOM);
}
return is_string($value) ? $value : null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\ActivityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class SocialActivityController extends Controller
{
public function __construct(private readonly ActivityService $activity) {}
public function index(Request $request): JsonResponse
{
$filter = (string) $request->query('filter', 'all');
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
return response()->json(
$this->activity->communityFeed(
viewer: $request->user(),
filter: $filter,
page: (int) $request->query('page', 1),
perPage: (int) $request->query('per_page', 20),
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
)
);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\StoryBookmark;
use App\Services\SocialService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class SocialCompatibilityController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function like(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'state' => ['nullable', 'boolean'],
]);
$state = array_key_exists('state', $payload)
? (bool) $payload['state']
: ! $request->isMethod('delete');
if ($payload['entity_type'] === 'story') {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
return response()->json([
'ok' => (bool) ($result['ok'] ?? true),
'liked' => (bool) ($result['liked'] ?? false),
'likes_count' => (int) ($result['likes_count'] ?? 0),
'is_liked' => (bool) ($result['liked'] ?? false),
'stats' => [
'likes' => (int) ($result['likes_count'] ?? 0),
],
]);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
return app(ArtworkInteractionController::class)->like(
$request->merge(['state' => $state]),
$artworkId,
);
}
public function comments(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
'parent_id' => ['nullable', 'integer'],
]);
if ($payload['entity_type'] === 'story') {
if ($request->isMethod('get')) {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
return response()->json(
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
);
}
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$comment = $this->social->addStoryComment(
$request->user(),
$story,
(string) $payload['content'],
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
);
return response()->json([
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
], 201);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
if ($request->isMethod('get')) {
return app(ArtworkCommentController::class)->index($request, $artworkId);
}
return app(ArtworkCommentController::class)->store(
$request->merge([
'content' => $payload['content'],
'parent_id' => $payload['parent_id'] ?? null,
]),
$artworkId,
);
}
public function bookmark(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'state' => ['nullable', 'boolean'],
]);
$state = array_key_exists('state', $payload)
? (bool) $payload['state']
: ! $request->isMethod('delete');
if ($payload['entity_type'] === 'story') {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
return response()->json([
'ok' => (bool) ($result['ok'] ?? true),
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
'stats' => [
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
],
]);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
return app(ArtworkInteractionController::class)->bookmark(
$request->merge(['state' => $state]),
$artworkId,
);
}
public function bookmarks(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$perPage = (int) ($payload['per_page'] ?? 20);
$userId = (int) $request->user()->id;
$type = $payload['entity_type'] ?? null;
$items = collect();
if ($type === null || $type === 'artwork') {
$items = $items->concat(
Schema::hasTable('artwork_bookmarks')
? DB::table('artwork_bookmarks')
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
->where('artwork_bookmarks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->select([
'artwork_bookmarks.created_at as saved_at',
'artworks.id',
'artworks.title',
'artworks.slug',
])
->latest('artwork_bookmarks.created_at')
->limit($perPage)
->get()
->map(fn ($row) => [
'type' => 'artwork',
'id' => (int) $row->id,
'title' => (string) $row->title,
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
])
: collect()
);
}
if ($type === null || $type === 'story') {
$items = $items->concat(
StoryBookmark::query()
->with('story:id,slug,title')
->where('user_id', $userId)
->latest('created_at')
->limit($perPage)
->get()
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
->map(fn (StoryBookmark $bookmark) => [
'type' => 'story',
'id' => (int) $bookmark->story->id,
'title' => (string) $bookmark->story->title,
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
'saved_at' => $bookmark->created_at?->toIso8601String(),
])
);
}
return response()->json([
'data' => $items
->sortByDesc('saved_at')
->take($perPage)
->values()
->all(),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryComment;
use App\Services\SocialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StoryCommentController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function index(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
);
}
public function store(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
$payload = $request->validate([
'content' => ['required', 'string', 'min:1', 'max:10000'],
'parent_id' => ['nullable', 'integer'],
]);
$comment = $this->social->addStoryComment(
$request->user(),
$story,
(string) $payload['content'],
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
);
return response()->json([
'data' => $this->social->formatComment($comment, $request->user()->id, true),
], 201);
}
public function destroy(Request $request, int $storyId, int $commentId): JsonResponse
{
$comment = StoryComment::query()
->where('story_id', $storyId)
->findOrFail($commentId);
$this->social->deleteStoryComment($request->user(), $comment);
return response()->json(['message' => 'Comment deleted.']);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Services\SocialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StoryInteractionController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function like(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true))
);
}
public function bookmark(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true))
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\AchievementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserAchievementsController extends Controller
{
public function __invoke(Request $request, AchievementService $achievements): JsonResponse
{
return response()->json($achievements->summary($request->user()->id));
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserXpController extends Controller
{
public function __invoke(Request $request, XPService $xp): JsonResponse
{
return response()->json($xp->summary($request->user()->id));
}
}