diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php
index 183aaadb..de27b38e 100644
--- a/app/Http/Controllers/Api/ProfileApiController.php
+++ b/app/Http/Controllers/Api/ProfileApiController.php
@@ -10,6 +10,7 @@ use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
+use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -106,7 +107,7 @@ final class ProfileApiController extends Controller
return response()->json(['error' => 'User not found'], 404);
}
- $perPage = 24;
+ $perPage = 12;
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table($favouriteTable . ' as af')
@@ -116,6 +117,10 @@ final class ProfileApiController extends Controller
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
+ ->when(app(ArtworkMaturityService::class)->viewerPreferences($request->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
+ $query->whereRaw('COALESCE(a.is_mature, 0) = 0')
+ ->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
+ })
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
@@ -246,6 +251,8 @@ final class ProfileApiController extends Controller
return [
'id' => $art->id,
'name' => $art->title,
+ 'picture' => $art->file_name,
+ 'datum' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php
index c27a64c7..81657c33 100644
--- a/app/Http/Controllers/User/ProfileController.php
+++ b/app/Http/Controllers/User/ProfileController.php
@@ -1011,14 +1011,17 @@ class ProfileController extends Controller
? $this->loadFeaturedArtworks($user, $viewer)
: null;
- // ── Favourites ───────────────────────────────────────────────────────
- $favouriteLimit = 12;
- $favouriteTable = $this->resolveFavouriteTable();
- $favourites = [
- 'data' => [],
- 'next_cursor' => null,
- ];
- if ($favouriteTable !== null) {
+ // ── Favourites (only needed for the direct Favourites tab) ───────────
+ $favourites = null;
+ if ($initialTab === 'favourites') {
+ $favouriteLimit = 12;
+ $favouriteTable = $this->resolveFavouriteTable();
+ $favourites = [
+ 'data' => [],
+ 'next_cursor' => null,
+ ];
+ }
+ if ($favourites !== null && $favouriteTable !== null) {
$favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx
index d23ddc5c..07edfbba 100644
--- a/resources/js/Pages/Profile/ProfileShow.jsx
+++ b/resources/js/Pages/Profile/ProfileShow.jsx
@@ -16,6 +16,7 @@ import TabWorlds from '../../components/profile/tabs/TabWorlds'
import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
+import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
@@ -92,6 +93,11 @@ export default function ProfileShow() {
initialFeaturedArtworks: featuredArtworks,
enabled: activeTab === 'artworks',
})
+ const favouritesState = useLazyProfileFavourites({
+ username: user?.username || user?.name || '',
+ initialFavourites: favourites,
+ enabled: activeTab === 'favourites',
+ })
const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return
@@ -133,10 +139,8 @@ export default function ProfileShow() {
? artworks
: (artworks?.data ?? [])
const artworkNextCursor = artworks?.next_cursor ?? null
- const favouriteList = Array.isArray(favourites)
- ? favourites
- : (favourites?.data ?? [])
- const favouriteNextCursor = favourites?.next_cursor ?? null
+ const favouriteList = favouritesState.items
+ const favouriteNextCursor = favouritesState.nextCursor
// Normalise social links (may be object keyed by platform, or array)
const socialLinksObj = Array.isArray(socialLinks)
@@ -281,6 +285,9 @@ export default function ProfileShow() {
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
isOwner={isOwner}
username={user.username || user.name}
+ loading={favouritesState.loading}
+ error={favouritesState.error}
+ onRetry={favouritesState.retry}
/>
)}
{activeTab === 'activity' && (
diff --git a/resources/js/components/profile/tabs/TabFavourites.jsx b/resources/js/components/profile/tabs/TabFavourites.jsx
index 9d003b58..a5307781 100644
--- a/resources/js/components/profile/tabs/TabFavourites.jsx
+++ b/resources/js/components/profile/tabs/TabFavourites.jsx
@@ -13,7 +13,7 @@ function FavSkeleton() {
* TabFavourites
* Shows artworks the user has favourited.
*/
-export default function TabFavourites({ favourites, isOwner, username }) {
+export default function TabFavourites({ favourites, isOwner, username, loading = false, error = false, onRetry }) {
const initialItems = Array.isArray(favourites)
? favourites
: (favourites?.data ?? [])
@@ -80,7 +80,20 @@ export default function TabFavourites({ favourites, isOwner, username }) {
{isOwner ? 'Your Favourites' : 'Favourites'}
- {items.length === 0 ? (
+ {loading && items.length === 0 && (
+
+ {Array.from({ length: 4 }).map((_, i) => )}
+
+ )}
+
+ {error && items.length === 0 && (
+
+
Favourites could not be loaded.
+ {onRetry ?
: null}
+
+ )}
+
+ {!loading && !error && items.length === 0 ? (
diff --git a/resources/js/lib/useLazyProfileFavourites.js b/resources/js/lib/useLazyProfileFavourites.js
new file mode 100644
index 00000000..3181957f
--- /dev/null
+++ b/resources/js/lib/useLazyProfileFavourites.js
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export default function useLazyProfileFavourites({ username, initialFavourites, enabled }) {
+ const hasInitialValue = initialFavourites !== null && initialFavourites !== undefined
+ const initialItems = Array.isArray(initialFavourites)
+ ? initialFavourites
+ : (initialFavourites?.data ?? [])
+ const [items, setItems] = useState(() => initialItems)
+ const [nextCursor, setNextCursor] = useState(() => initialFavourites?.next_cursor ?? null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(false)
+ const requestKeyRef = useRef(username)
+ const requestedRef = useRef(hasInitialValue)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ if (requestKeyRef.current === username) return undefined
+
+ abortRef.current?.abort()
+ abortRef.current = null
+ requestKeyRef.current = username
+ requestedRef.current = false
+ setItems([])
+ setNextCursor(null)
+ setLoading(false)
+ setError(false)
+ return undefined
+ }, [username])
+
+ useEffect(() => {
+ if (requestKeyRef.current !== username || !hasInitialValue) return undefined
+
+ abortRef.current?.abort()
+ abortRef.current = null
+ requestedRef.current = true
+ setItems(initialItems)
+ setNextCursor(initialFavourites?.next_cursor ?? null)
+ setLoading(false)
+ setError(false)
+ return undefined
+ }, [hasInitialValue, initialFavourites, initialItems, username])
+
+ const load = useCallback(async () => {
+ if (!enabled || hasInitialValue || requestedRef.current || !username) return
+
+ requestedRef.current = true
+ const controller = new AbortController()
+ const requestKey = requestKeyRef.current
+ abortRef.current = controller
+ setLoading(true)
+ setError(false)
+
+ try {
+ const response = await fetch(`/api/profile/${encodeURIComponent(username)}/favourites`, {
+ headers: { Accept: 'application/json' },
+ credentials: 'same-origin',
+ signal: controller.signal,
+ })
+ if (!response.ok) throw new Error('Favourites could not be loaded.')
+ const payload = await response.json()
+ if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
+ setItems(Array.isArray(payload?.data) ? payload.data : [])
+ setNextCursor(payload?.next_cursor ?? null)
+ } catch (caught) {
+ if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
+ requestedRef.current = false
+ setError(true)
+ } finally {
+ if (requestKey === requestKeyRef.current) {
+ abortRef.current = null
+ setLoading(false)
+ }
+ }
+ }, [enabled, hasInitialValue, username])
+
+ useEffect(() => {
+ if (enabled) load()
+ }, [enabled, load])
+
+ useEffect(() => () => abortRef.current?.abort(), [])
+
+ return { items, nextCursor, loading, error, retry: load }
+}
diff --git a/resources/js/lib/useLazyProfileFavourites.test.jsx b/resources/js/lib/useLazyProfileFavourites.test.jsx
new file mode 100644
index 00000000..20315653
--- /dev/null
+++ b/resources/js/lib/useLazyProfileFavourites.test.jsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import { render, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import useLazyProfileFavourites from './useLazyProfileFavourites'
+
+function Harness({ username = 'alice', initial = null, enabled = false }) {
+ const state = useLazyProfileFavourites({
+ username,
+ initialFavourites: initial,
+ enabled,
+ })
+
+ return
+}
+
+describe('useLazyProfileFavourites', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('does not request before activation and requests page one once on activation', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: [{ id: 1 }], next_cursor: 'c1' }),
+ })
+ const view = render()
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ view.rerender()
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+ expect(fetchMock).toHaveBeenCalledWith('/api/profile/alice/favourites', expect.objectContaining({
+ credentials: 'same-origin',
+ }))
+ })
+
+ it('uses SSR data without a page-one request and survives rerenders', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ const view = render()
+ view.rerender()
+
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 9 }]))
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('does not refetch after switching away and back', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: [{ id: 3 }], next_cursor: null }),
+ })
+ const view = render()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+
+ view.rerender()
+ view.rerender()
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 3 }]))
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('resets profile state and ignores a stale response', async () => {
+ let resolveRequest
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
+ resolveRequest = resolve
+ }))
+ const view = render()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+
+ view.rerender()
+ expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([])
+ resolveRequest({ ok: true, json: async () => ({ data: [{ id: 1 }], next_cursor: null }) })
+
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([]))
+ })
+})
diff --git a/tests/Feature/Profile/ProfileFavouritesLazyTest.php b/tests/Feature/Profile/ProfileFavouritesLazyTest.php
new file mode 100644
index 00000000..d913335f
--- /dev/null
+++ b/tests/Feature/Profile/ProfileFavouritesLazyTest.php
@@ -0,0 +1,74 @@
+create(['username' => 'lazyfavourites']);
+ $artworks = Artwork::factory()->count(13)->for($user)->create();
+
+ foreach ($artworks as $index => $artwork) {
+ DB::table('artwork_favourites')->insert([
+ 'user_id' => $user->id,
+ 'artwork_id' => $artwork->id,
+ 'created_at' => now()->subMinutes($index),
+ 'updated_at' => now()->subMinutes($index),
+ ]);
+ }
+
+ return [$user, $artworks];
+}
+
+it('skips the favourites producer on the default Posts SSR request', function (): void {
+ [$user] = profileFavouritesFixture();
+ DB::flushQueryLog();
+ DB::enableQueryLog();
+
+ $this->get('/@' . $user->username)
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('initialTab', 'posts')
+ ->where('favourites', null));
+
+ expect(collect(DB::getQueryLog())->pluck('query')->filter(fn (string $query): bool => str_contains(strtolower($query), 'artwork_favourites'))->all())->toBe([]);
+});
+
+it('keeps the first favourites page in direct SSR with the existing cursor contract', function (): void {
+ [$user, $artworks] = profileFavouritesFixture();
+
+ $this->get('/@' . $user->username . '/favourites')
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('initialTab', 'favourites')
+ ->where('favourites.next_cursor', base64_encode('12'))
+ ->where('favourites.data.0.id', $artworks->first()->id));
+});
+
+it('returns the same ordered twelve-item page and continues with the next cursor', function (): void {
+ [$user, $artworks] = profileFavouritesFixture();
+ $expected = $artworks->values();
+
+ $first = $this->getJson('/api/profile/' . $user->username . '/favourites')
+ ->assertOk()
+ ->assertJsonCount(12, 'data')
+ ->assertJsonPath('data.0.id', $expected[0]->id)
+ ->assertJsonPath('next_cursor', base64_encode('12'));
+
+ $this->getJson('/api/profile/' . $user->username . '/favourites?cursor=' . urlencode($first->json('next_cursor')))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.id', $expected[12]->id)
+ ->assertJsonPath('next_cursor', null);
+});
+
+it('does not increment profile views for the lazy favourites endpoint', function (): void {
+ [$user] = profileFavouritesFixture();
+ $before = (int) $user->profile_views_count;
+
+ $this->getJson('/api/profile/' . $user->username . '/favourites')->assertOk();
+
+ expect((int) $user->fresh()->profile_views_count)->toBe($before);
+});