Defer profile featured artworks

This commit is contained in:
2026-08-29 16:33:36 +02:00
parent ff80f5bf97
commit 872f420190
7 changed files with 336 additions and 39 deletions
+66 -37
View File
@@ -189,6 +189,24 @@ class ProfileController extends Controller
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab); 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) public function legacyById(Request $request, int $id, ?string $username = null)
{ {
$user = User::query()->findOrFail($id); $user = User::query()->findOrFail($id);
@@ -989,42 +1007,9 @@ class ProfileController extends Controller
}); });
// ── Featured artworks for this user ───────────────────────────────── // ── Featured artworks for this user ─────────────────────────────────
$featuredArtworks = collect(); $featuredArtworks = $initialTab === 'artworks'
if (Schema::hasTable('artwork_features')) { ? $this->loadFeaturedArtworks($user, $viewer)
$featuredQuery = Artwork::query() : null;
->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),
]);
});
}
// ── Favourites ─────────────────────────────────────────────────────── // ── Favourites ───────────────────────────────────────────────────────
$favouriteLimit = 12; $favouriteLimit = 12;
@@ -1397,7 +1382,7 @@ class ProfileController extends Controller
'cover_image' => $profile->cover_image ?? null, 'cover_image' => $profile->cover_image ?? null,
] : null, ] : null,
'artworks' => $artworkPayload, 'artworks' => $artworkPayload,
'featuredArtworks' => $featuredArtworks->values(), 'featuredArtworks' => $featuredArtworks?->values(),
'favourites' => $favourites, 'favourites' => $favourites,
'stats' => $statsPayload, 'stats' => $statsPayload,
'socialLinks' => $socialLinks, '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 private function normalizeProfileTab(mixed $tab): ?string
{ {
if (! is_string($tab)) { if (! is_string($tab)) {
+10 -1
View File
@@ -15,6 +15,7 @@ import TabStories from '../../components/profile/tabs/TabStories'
import TabWorlds from '../../components/profile/tabs/TabWorlds' import TabWorlds from '../../components/profile/tabs/TabWorlds'
import TabFollowers from '../../components/profile/tabs/TabFollowers' import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary' 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'] const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
@@ -86,6 +87,11 @@ export default function ProfileShow() {
} = props } = props
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab)) const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
const featuredState = useLazyProfileFeaturedArtworks({
username: user?.username || user?.name || '',
initialFeaturedArtworks: featuredArtworks,
enabled: activeTab === 'artworks',
})
const handleTabChange = useCallback((tab) => { const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return if (!VALID_TABS.includes(tab)) return
@@ -195,7 +201,10 @@ export default function ProfileShow() {
{activeTab === 'artworks' && ( {activeTab === 'artworks' && (
<TabArtworks <TabArtworks
artworks={{ data: artworkList, next_cursor: artworkNextCursor }} artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
featuredArtworks={featuredArtworks} featuredArtworks={featuredState.items}
featuredLoading={featuredState.loading}
featuredError={featuredState.error}
onRetryFeatured={featuredState.retry}
username={user.username || user.name} username={user.username || user.name}
galleryUrl={galleryUrl} galleryUrl={galleryUrl}
isActive isActive
@@ -242,7 +242,7 @@ function FullGalleryCta({ galleryUrl, username }) {
) )
} }
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) { export default function TabArtworks({ artworks, featuredArtworks, featuredLoading = false, featuredError = false, onRetryFeatured, username, galleryUrl }) {
const initialItems = artworks?.data ?? artworks ?? [] const initialItems = artworks?.data ?? artworks ?? []
const trendingItems = useArtworkPreview(username, 'trending') const trendingItems = useArtworkPreview(username, 'trending')
const popularItems = useArtworkPreview(username, 'views') const popularItems = useArtworkPreview(username, 'views')
@@ -261,6 +261,16 @@ export default function TabArtworks({ artworks, featuredArtworks, username, gall
aria-labelledby="tab-artworks" aria-labelledby="tab-artworks"
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6" className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
> >
{featuredLoading ? (
<div className="mb-8 rounded-[28px] border border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-400" role="status">
Loading featured artworks…
</div>
) : featuredError ? (
<div className="mb-8 rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-5 py-4 text-sm text-rose-100" role="alert">
Featured artworks could not be loaded.
{onRetryFeatured ? <button type="button" className="ml-3 underline" onClick={onRetryFeatured}>Retry</button> : null}
</div>
) : null}
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} /> <FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
<PreviewRail <PreviewRail
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export default function useLazyProfileFeaturedArtworks({ username, initialFeaturedArtworks, enabled }) {
const hasInitialValue = Array.isArray(initialFeaturedArtworks)
const [items, setItems] = useState(() => 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 }
}
@@ -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 (
<div>
<span data-testid="count">{state.items.length}</span>
<span data-testid="loading">{String(state.loading)}</span>
<span data-testid="error">{String(state.error)}</span>
<button type="button" onClick={state.retry}>retry</button>
{state.items.map((item) => <span key={item.id}>{item.title}</span>)}
</div>
)
}
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(<Harness />)
expect(fetch).not.toHaveBeenCalled()
})
it('loads once on first artworks activation and reuses the result', async () => {
const view = render(<Harness enabled />)
await waitFor(() => expect(screen.getByText('Featured one')).toBeTruthy())
expect(fetch).toHaveBeenCalledTimes(1)
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not request when SSR already supplied featured artworks', () => {
render(<Harness enabled initial={[{ id: 9, title: 'SSR feature' }]} />)
expect(screen.getByText('SSR feature')).toBeTruthy()
expect(fetch).not.toHaveBeenCalled()
})
it('allows a safe retry after failure', async () => {
fetch.mockRejectedValueOnce(new Error('network'))
render(<Harness enabled />)
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(<Harness username="alice" enabled />)
view.rerender(<Harness username="bob" enabled={false} />)
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')
})
})
+1
View File
@@ -910,6 +910,7 @@ Route::middleware(['web', 'throttle:60,1'])
Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks'); 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('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats'); 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) ───────────────────────────────────────────── // ── Link Preview (auth, throttled) ─────────────────────────────────────────────
@@ -0,0 +1,88 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
function profileFeaturedFixture(): array
{
$user = User::factory()->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();
});