From 872f42019099d0211f5933f7e4e72cf80051d2a2 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 29 Aug 2026 16:33:36 +0200 Subject: [PATCH] Defer profile featured artworks --- .../Controllers/User/ProfileController.php | 103 +++++++++++------- resources/js/Pages/Profile/ProfileShow.jsx | 11 +- .../components/profile/tabs/TabArtworks.jsx | 12 +- .../js/lib/useLazyProfileFeaturedArtworks.js | 83 ++++++++++++++ .../useLazyProfileFeaturedArtworks.test.jsx | 77 +++++++++++++ routes/api.php | 1 + .../Profile/ProfileFeaturedArtworksTest.php | 88 +++++++++++++++ 7 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 resources/js/lib/useLazyProfileFeaturedArtworks.js create mode 100644 resources/js/lib/useLazyProfileFeaturedArtworks.test.jsx create mode 100644 tests/Feature/Profile/ProfileFeaturedArtworksTest.php diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index fceb80da..c27a64c7 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -189,6 +189,24 @@ class ProfileController extends Controller return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab); } + /** + * Read-only featured artwork payload for the client-side Artworks tab. + * This deliberately does not render the profile document or track a view. + */ + public function featuredArtworks(Request $request, string $username): JsonResponse + { + $normalized = UsernamePolicy::normalize($username); + $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + + if (! $user) { + return response()->json(['error' => 'User not found'], 404); + } + + return response()->json([ + 'data' => $this->loadFeaturedArtworks($user, $request->user())->values(), + ]); + } + public function legacyById(Request $request, int $id, ?string $username = null) { $user = User::query()->findOrFail($id); @@ -989,42 +1007,9 @@ class ProfileController extends Controller }); // ── Featured artworks for this user ───────────────────────────────── - $featuredArtworks = collect(); - if (Schema::hasTable('artwork_features')) { - $featuredQuery = Artwork::query() - ->with([ - 'user:id,name,username,level,rank', - 'user.profile:user_id,avatar_hash', - 'group:id,name,slug,avatar_path', - 'stats:artwork_id,views,downloads,favorites', - 'categories' => function ($query) { - $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') - ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); - }, - ]) - ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') - ->where('artworks.user_id', $user->id) - ->where('af.is_active', true) - ->whereNull('af.deleted_at') - ->whereNull('artworks.deleted_at') - ->where('artworks.is_public', true) - ->where('artworks.is_approved', true) - ->whereNotNull('artworks.published_at') - ->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at']) - ->orderByDesc('af.featured_at') - ->limit(3); - - app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer); - - $featuredArtworks = $featuredQuery - ->get() - ->map(function (Artwork $artwork) { - return (object) array_merge($this->mapArtworkCardPayload($artwork), [ - 'label' => $artwork->featured_label, - 'featured_at' => $this->formatIsoDate($artwork->featured_slot_at), - ]); - }); - } + $featuredArtworks = $initialTab === 'artworks' + ? $this->loadFeaturedArtworks($user, $viewer) + : null; // ── Favourites ─────────────────────────────────────────────────────── $favouriteLimit = 12; @@ -1397,7 +1382,7 @@ class ProfileController extends Controller 'cover_image' => $profile->cover_image ?? null, ] : null, 'artworks' => $artworkPayload, - 'featuredArtworks' => $featuredArtworks->values(), + 'featuredArtworks' => $featuredArtworks?->values(), 'favourites' => $favourites, 'stats' => $statsPayload, 'socialLinks' => $socialLinks, @@ -1440,6 +1425,50 @@ class ProfileController extends Controller ]); } + /** + * Build the canonical featured artwork representation shared by SSR and + * the lazy Artworks-tab endpoint. + */ + private function loadFeaturedArtworks(User $user, ?User $viewer): ?\Illuminate\Support\Collection + { + if (! Schema::hasTable('artwork_features')) { + return collect(); + } + + $featuredQuery = Artwork::query() + ->with([ + 'user:id,name,username,level,rank', + 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', + 'stats:artwork_id,views,downloads,favorites', + 'categories' => function ($query) { + $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); + }, + ]) + ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') + ->where('artworks.user_id', $user->id) + ->where('af.is_active', true) + ->whereNull('af.deleted_at') + ->whereNull('artworks.deleted_at') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNotNull('artworks.published_at') + ->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at']) + ->orderByDesc('af.featured_at') + ->limit(3); + + app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer); + + return $featuredQuery->get() + ->map(function (Artwork $artwork) { + return (object) array_merge($this->mapArtworkCardPayload($artwork), [ + 'label' => $artwork->featured_label, + 'featured_at' => $this->formatIsoDate($artwork->featured_slot_at), + ]); + }); + } + private function normalizeProfileTab(mixed $tab): ?string { if (! is_string($tab)) { diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index 3d14dae3..d23ddc5c 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -15,6 +15,7 @@ import TabStories from '../../components/profile/tabs/TabStories' 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' const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers'] @@ -86,6 +87,11 @@ export default function ProfileShow() { } = props const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab)) + const featuredState = useLazyProfileFeaturedArtworks({ + username: user?.username || user?.name || '', + initialFeaturedArtworks: featuredArtworks, + enabled: activeTab === 'artworks', + }) const handleTabChange = useCallback((tab) => { if (!VALID_TABS.includes(tab)) return @@ -195,7 +201,10 @@ export default function ProfileShow() { {activeTab === 'artworks' && ( + {featuredLoading ? ( +
+ Loading featured artworks… +
+ ) : featuredError ? ( +
+ Featured artworks could not be loaded. + {onRetryFeatured ? : null} +
+ ) : null} hasInitialValue ? initialFeaturedArtworks : []) + const [loaded, setLoaded] = useState(hasInitialValue) + 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([]) + setLoaded(false) + setLoading(false) + setError(false) + + return undefined + }, [username]) + + useEffect(() => { + if (!hasInitialValue || requestKeyRef.current !== username) return + + abortRef.current?.abort() + abortRef.current = null + requestedRef.current = true + setItems(initialFeaturedArtworks) + setLoaded(true) + setLoading(false) + setError(false) + }, [hasInitialValue, initialFeaturedArtworks, 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)}/featured-artworks`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + }) + + if (!response.ok) throw new Error('Featured artworks could not be loaded.') + + const payload = await response.json() + if (requestKey !== requestKeyRef.current || controller.signal.aborted) return + + setItems(Array.isArray(payload?.data) ? payload.data : []) + setLoaded(true) + } 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, loaded, loading, error, retry: load } +} diff --git a/resources/js/lib/useLazyProfileFeaturedArtworks.test.jsx b/resources/js/lib/useLazyProfileFeaturedArtworks.test.jsx new file mode 100644 index 00000000..ffeb1c60 --- /dev/null +++ b/resources/js/lib/useLazyProfileFeaturedArtworks.test.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import useLazyProfileFeaturedArtworks from './useLazyProfileFeaturedArtworks' + +function Harness({ username = 'alice', initial = null, enabled = false }) { + const state = useLazyProfileFeaturedArtworks({ + username, + initialFeaturedArtworks: initial, + enabled, + }) + + return ( +
+ {state.items.length} + {String(state.loading)} + {String(state.error)} + + {state.items.map((item) => {item.title})} +
+ ) +} + +describe('useLazyProfileFeaturedArtworks', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: 1, title: 'Featured one' }] }), + }))) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('does not request while the posts tab is active', () => { + render() + expect(fetch).not.toHaveBeenCalled() + }) + + it('loads once on first artworks activation and reuses the result', async () => { + const view = render() + + await waitFor(() => expect(screen.getByText('Featured one')).toBeTruthy()) + expect(fetch).toHaveBeenCalledTimes(1) + + view.rerender() + view.rerender() + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not request when SSR already supplied featured artworks', () => { + render() + expect(screen.getByText('SSR feature')).toBeTruthy() + expect(fetch).not.toHaveBeenCalled() + }) + + it('allows a safe retry after failure', async () => { + fetch.mockRejectedValueOnce(new Error('network')) + render() + + await waitFor(() => expect(screen.getByTestId('error').textContent).toBe('true')) + await act(async () => screen.getByRole('button', { name: 'retry' }).click()) + await waitFor(() => expect(screen.getByText('Featured one')).toBeTruthy()) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('ignores a stale response after profile navigation', async () => { + let resolveAlice + fetch.mockImplementationOnce(() => new Promise((resolve) => { resolveAlice = resolve })) + const view = render() + + view.rerender() + await act(async () => resolveAlice({ ok: true, json: async () => ({ data: [{ id: 1, title: 'Alice feature' }] }) })) + + expect(screen.queryByText('Alice feature')).toBeNull() + expect(screen.getByTestId('count').textContent).toBe('0') + }) +}) diff --git a/routes/api.php b/routes/api.php index bb0396aa..c588bdef 100644 --- a/routes/api.php +++ b/routes/api.php @@ -910,6 +910,7 @@ Route::middleware(['web', 'throttle:60,1']) Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks'); Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites'); Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats'); + Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks'); }); // ── Link Preview (auth, throttled) ───────────────────────────────────────────── diff --git a/tests/Feature/Profile/ProfileFeaturedArtworksTest.php b/tests/Feature/Profile/ProfileFeaturedArtworksTest.php new file mode 100644 index 00000000..8bcad140 --- /dev/null +++ b/tests/Feature/Profile/ProfileFeaturedArtworksTest.php @@ -0,0 +1,88 @@ +create(['username' => 'featuredprofile']); + $older = Artwork::factory()->for($user)->create([ + 'title' => 'Older featured artwork', + 'published_at' => now()->subDays(2), + ]); + $newer = Artwork::factory()->for($user)->create([ + 'title' => 'Newer featured artwork', + 'published_at' => now()->subDay(), + ]); + + DB::table('artwork_features')->insert([ + [ + 'artwork_id' => $older->id, + 'label' => 'Older', + 'is_active' => true, + 'featured_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'artwork_id' => $newer->id, + 'label' => 'Newer', + 'is_active' => true, + 'featured_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + return [$user, $older, $newer]; +} + +it('does not include featured artworks in the default posts SSR payload', function () { + [$user] = profileFeaturedFixture(); + + $this->get('/@' . $user->username) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('initialTab', 'posts') + ->where('featuredArtworks', null)); +}); + +it('keeps featured artworks in the direct artworks SSR payload', function () { + [$user, $older, $newer] = profileFeaturedFixture(); + + $this->get('/@' . $user->username . '/artworks') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('initialTab', 'artworks') + ->where('featuredArtworks.0.id', $newer->id) + ->where('featuredArtworks.1.id', $older->id)); +}); + +it('returns the same ordered featured collection from the lazy endpoint', function () { + [$user, $older, $newer] = profileFeaturedFixture(); + + $response = $this->getJson('/api/profile/' . $user->username . '/featured-artworks'); + + $response->assertOk() + ->assertJsonPath('data.0.id', $newer->id) + ->assertJsonPath('data.1.id', $older->id) + ->assertJsonPath('data.0.label', 'Newer'); +}); + +it('does not track a profile view for the lazy featured endpoint', function () { + [$user] = profileFeaturedFixture(); + $before = (int) ($user->profile_views_count ?? 0); + + $this->getJson('/api/profile/' . $user->username . '/featured-artworks')->assertOk(); + + expect((int) $user->fresh()->profile_views_count)->toBe($before); +}); + +it('returns not found for an unknown featured-artworks profile', function () { + $this->getJson('/api/profile/missing-featured-user/featured-artworks') + ->assertNotFound(); +});