diff --git a/app/Http/Controllers/Api/ArtworkNavigationController.php b/app/Http/Controllers/Api/ArtworkNavigationController.php index 7330a9b5..9504ef89 100644 --- a/app/Http/Controllers/Api/ArtworkNavigationController.php +++ b/app/Http/Controllers/Api/ArtworkNavigationController.php @@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Resources\ArtworkResource; use App\Models\Artwork; +use App\Services\ArtworkNavigationService; +use App\Services\ThumbnailPresenter; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Str; +use Illuminate\Http\Request; class ArtworkNavigationController extends Controller { + public function __construct(private readonly ArtworkNavigationService $navigation) {} + /** * GET /api/artworks/navigation/{id} * @@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller 'prev_id' => null, 'next_id' => null, 'prev_url' => null, 'next_url' => null, 'prev_slug' => null, 'next_slug' => null, + 'prev_preview' => null, 'next_preview' => null, ]); } - $scope = Artwork::published() - ->select(['id', 'title', 'slug']) - ->where('user_id', $artwork->user_id); - - $prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first(); - $next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first(); - - // Infinite loop: wrap around when reaching the first or last artwork - if (! $prev) { - $prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first(); - } - if (! $next) { - $next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first(); - } - - $prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null; - $nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null; - - return response()->json([ - 'prev_id' => $prev?->id, - 'next_id' => $next?->id, - 'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null, - 'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null, - 'prev_slug' => $prevSlug, - 'next_slug' => $nextSlug, - ]); + return response()->json($this->navigation->navigationFor($artwork)); } /** @@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller * * Returns full artwork resource by numeric ID for client-side (no-reload) navigation. */ - public function pageData(int $id): JsonResponse + public function pageData(Request $request, int $id): JsonResponse { + if ($request->boolean('prefetch')) { + return $this->prefetchData($id); + } + $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats']) ->published() ->find($id); @@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller } $resource = (new ArtworkResource($artwork))->toArray(request()); + $resource['navigation'] = $this->navigation->navigationFor($artwork); return response()->json($resource); } + + private function prefetchData(int $id): JsonResponse + { + $artwork = Artwork::query() + ->published() + ->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name']) + ->find($id); + + if (! $artwork) { + return response()->json(['error' => 'Not found'], 404); + } + + return response()->json([ + 'id' => (int) $artwork->id, + 'title' => (string) $artwork->title, + 'slug' => (string) $artwork->slug, + 'thumbs' => [ + 'md' => ThumbnailPresenter::present($artwork, 'md'), + 'lg' => ThumbnailPresenter::present($artwork, 'lg'), + ], + ]); + } } diff --git a/app/Services/ArtworkNavigationService.php b/app/Services/ArtworkNavigationService.php new file mode 100644 index 00000000..20951cb3 --- /dev/null +++ b/app/Services/ArtworkNavigationService.php @@ -0,0 +1,70 @@ +select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name']) + ->where('user_id', $artwork->user_id); + + $prev = (clone $scope)->where('id', '<', $artwork->id)->orderByDesc('id')->first(); + $next = (clone $scope)->where('id', '>', $artwork->id)->orderBy('id')->first(); + + if (! $prev) { + $prev = (clone $scope)->where('id', '!=', $artwork->id)->orderByDesc('id')->first(); + } + if (! $next) { + $next = (clone $scope)->where('id', '!=', $artwork->id)->orderBy('id')->first(); + } + + $prevSlug = $this->canonicalSlug($prev); + $nextSlug = $this->canonicalSlug($next); + + return [ + 'prev_id' => $prev?->id, + 'next_id' => $next?->id, + 'prev_url' => $prev ? url('/art/'.$prev->id.'/'.$prevSlug) : null, + 'next_url' => $next ? url('/art/'.$next->id.'/'.$nextSlug) : null, + 'prev_slug' => $prevSlug, + 'next_slug' => $nextSlug, + 'prev_preview' => $this->preview($prev), + 'next_preview' => $this->preview($next), + ]; + } + + private function canonicalSlug(?Artwork $artwork): ?string + { + if (! $artwork) { + return null; + } + + return Str::slug($artwork->slug ?: $artwork->title) ?: (string) $artwork->id; + } + + private function preview(?Artwork $artwork): ?array + { + if (! $artwork) { + return null; + } + + return [ + 'id' => (int) $artwork->id, + 'title' => (string) $artwork->title, + 'slug' => (string) $artwork->slug, + 'thumbs' => [ + 'md' => ThumbnailPresenter::present($artwork, 'md'), + 'lg' => ThumbnailPresenter::present($artwork, 'lg'), + ], + ]; + } +} diff --git a/resources/js/components/viewer/ArtworkNavigator.jsx b/resources/js/components/viewer/ArtworkNavigator.jsx index 767a0047..f1a8c8b9 100644 --- a/resources/js/components/viewer/ArtworkNavigator.jsx +++ b/resources/js/components/viewer/ArtworkNavigator.jsx @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavContext } from '../../lib/useNavContext'; const preloadCache = new Set(); +const prefetchCache = new Set(); function scheduleIdleTask(callback, delay = 1200) { if (typeof window === 'undefined') { @@ -32,8 +33,8 @@ function preloadImage(src) { img.src = src; } -export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { - const { getNeighbors } = useNavContext(artworkId); +export default function ArtworkNavigator({ artworkId, navigation = null, onNavigate, onOpenViewer, onReady }) { + const { getNeighbors } = useNavContext(artworkId, navigation); const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null }); // Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values) @@ -67,17 +68,42 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean); if (ids.length === 0) return undefined; + const previews = new Map([ + [neighbors.prevId, neighbors.prevPreview], + [neighbors.nextId, neighbors.nextPreview], + ]); + let cancelled = false; const cancelIdleTask = scheduleIdleTask(() => { ids.forEach((id) => { - fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } }) - .then((r) => r.ok ? r.json() : null) + if (prefetchCache.has(id)) return; + prefetchCache.add(id); + const preview = previews.get(id); + if (preview) { + if (!cancelled) { + const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url; + if (imgUrl) preloadImage(imgUrl); + } + return; + } + fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: 'application/json' } }) + .then((r) => { + if (!r.ok) { + prefetchCache.delete(id); + return null; + } + return r.json(); + }) .then((data) => { - if (cancelled || !data) return; + if (!data) { + prefetchCache.delete(id); + return; + } + if (cancelled) return; const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url; if (imgUrl) preloadImage(imgUrl); }) - .catch(() => {}); + .catch(() => prefetchCache.delete(id)); }); }); @@ -85,7 +111,7 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, cancelled = true; cancelIdleTask(); }; - }, [neighbors.prevId, neighbors.nextId]); + }, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]); // Stable navigate — reads state via refs, never recreated const navigate = useCallback(async (targetId, targetUrl) => { diff --git a/resources/js/components/viewer/ArtworkNavigator.prefetch.test.jsx b/resources/js/components/viewer/ArtworkNavigator.prefetch.test.jsx new file mode 100644 index 00000000..f617db9d --- /dev/null +++ b/resources/js/components/viewer/ArtworkNavigator.prefetch.test.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import ArtworkNavigator from './ArtworkNavigator' + +describe('ArtworkNavigator preview prefetch', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + delete window.requestIdleCallback + delete window.cancelIdleCallback + }) + + it('seeds neighbor prefetch from navigation and keeps full navigation separate', async () => { + let idleCallback + let ready + window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 }) + window.cancelIdleCallback = vi.fn() + global.fetch = vi.fn((url) => { + if (String(url).includes('/api/artworks/navigation/777')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + prev_id: 776, + next_id: 778, + prev_url: '/art/776/previous', + next_url: '/art/778/next', + prev_slug: 'previous', + next_slug: 'next', + prev_preview: { id: 776, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-776' }, lg: { url: '/lg-776' } } }, + next_preview: { id: 778, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-778' }, lg: { url: '/lg-778' } } }, + }), + }) + } + + return Promise.resolve({ ok: true, json: async () => ({ id: 778, title: 'Next', slug: 'next' }) }) + }) + + render( { if (next.hasNext) ready = next }} />) + await waitFor(() => expect(ready?.hasNext).toBe(true)) + await act(async () => idleCallback()) + + expect(global.fetch.mock.calls.filter(([url]) => String(url).includes('?prefetch=1'))).toHaveLength(0) + await act(async () => ready.navigateNext()) + expect(global.fetch).toHaveBeenCalledWith('/api/artworks/778/page', { headers: { Accept: 'application/json' } }) + }) + + it('uses embedded initial navigation without a standalone or prefetch request', async () => { + let idleCallback + window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 }) + window.cancelIdleCallback = vi.fn() + global.fetch = vi.fn() + const navigation = { + prev_id: 880, + next_id: 882, + prev_url: '/art/880/previous', + next_url: '/art/882/next', + prev_slug: 'previous', + next_slug: 'next', + prev_preview: { id: 880, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-880' }, lg: { url: '/lg-880' } } }, + next_preview: { id: 882, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-882' }, lg: { url: '/lg-882' } } }, + } + + render() + await waitFor(() => expect(window.requestIdleCallback).toHaveBeenCalledTimes(1)) + await act(async () => idleCallback()) + + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('falls back to standalone navigation for malformed embedded data', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ prev_id: 889, next_id: 891, prev_url: '/art/889/p', next_url: '/art/891/n', prev_slug: 'p', next_slug: 'n' }), + }) + + render() + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/artworks/navigation/890', { headers: { Accept: 'application/json' } })) + }) +}) diff --git a/resources/js/lib/useNavContext.js b/resources/js/lib/useNavContext.js index 718ac911..b227a5e3 100644 --- a/resources/js/lib/useNavContext.js +++ b/resources/js/lib/useNavContext.js @@ -8,6 +8,22 @@ import { useCallback } from 'react'; // Module-level cache for API calls const fallbackCache = new Map(); +function normalizeNavigation(data) { + if (!data || typeof data !== 'object') return null; + const hasPrev = data.prev_id == null || (data.prev_url && data.prev_slug); + const hasNext = data.next_id == null || (data.next_url && data.next_slug); + if (!hasPrev || !hasNext) return null; + + return { + prevId: data.prev_id ?? null, + nextId: data.next_id ?? null, + prevUrl: data.prev_url ?? null, + nextUrl: data.next_url ?? null, + prevPreview: data.prev_preview ?? null, + nextPreview: data.next_preview ?? null, + }; +} + async function fetchFallback(artworkId) { const key = String(artworkId); if (fallbackCache.has(key)) return fallbackCache.get(key); @@ -16,28 +32,25 @@ async function fetchFallback(artworkId) { const res = await fetch(`/api/artworks/navigation/${artworkId}`, { headers: { Accept: 'application/json' }, }); - if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; + if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; const data = await res.json(); - const result = { - prevId: data.prev_id ?? null, - nextId: data.next_id ?? null, - prevUrl: data.prev_url ?? null, - nextUrl: data.next_url ?? null, - }; + const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; fallbackCache.set(key, result); return result; } catch { - return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; + return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; } } -export function useNavContext(currentArtworkId) { +export function useNavContext(currentArtworkId, embeddedNavigation = null) { /** * Always resolve via API to guarantee same-user navigation. */ const getNeighbors = useCallback(async () => { + const embedded = normalizeNavigation(embeddedNavigation); + if (embedded) return embedded; return fetchFallback(currentArtworkId); - }, [currentArtworkId]); + }, [currentArtworkId, embeddedNavigation]); return { getNeighbors }; } diff --git a/tests/Feature/Artworks/M18B3ArtworkNavigationPrefetchTest.php b/tests/Feature/Artworks/M18B3ArtworkNavigationPrefetchTest.php new file mode 100644 index 00000000..0eb9d61f --- /dev/null +++ b/tests/Feature/Artworks/M18B3ArtworkNavigationPrefetchTest.php @@ -0,0 +1,98 @@ +create([ + 'title' => 'Prefetch Artwork', + 'slug' => 'prefetch-artwork', + 'hash' => 'aabbccddeeff00112233445566778899', + 'file_path' => 'uploads/prefetch.jpg', + ]); + $queries = 0; + + DB::listen(function () use (&$queries): void { + $queries++; + }); + + $response = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1') + ->assertOk(); + + expect($queries)->toBe(1) + ->and($response->json())->toHaveKeys(['id', 'title', 'slug', 'thumbs']) + ->and($response->json('thumbs'))->toHaveKeys(['md', 'lg']) + ->and($response->json('thumbs.xl'))->toBeNull() + ->and($response->json('stats'))->toBeNull() + ->and($response->json('thumbs.lg.url'))->toBe( + 'https://cdn.skinbase.org/artworks/lg/aa/bb/aabbccddeeff00112233445566778899.webp' + ); +}); + +it('does not prefetch missing or unpublished artwork', function (): void { + $unpublished = Artwork::factory()->unpublished()->create(); + + $this->getJson('/api/artworks/'.$unpublished->id.'/page?prefetch=1') + ->assertNotFound(); + + $this->getJson('/api/artworks/999999/page?prefetch=1') + ->assertNotFound(); +}); + +it('keeps full page navigation data available separately from prefetch', function (): void { + $artwork = Artwork::factory()->create([ + 'slug' => 'full-navigation-artwork', + ]); + + $this->getJson('/api/artworks/'.$artwork->id.'/page') + ->assertOk() + ->assertJsonStructure([ + 'id', 'title', 'thumbs', 'user', 'categories', 'tags', 'stats', 'viewer', + ]); +}); + +it('measures the lightweight prefetch contract for guest and authenticated viewers', function (): void { + $artwork = Artwork::factory()->create([ + 'slug' => 'prefetch-measurement-artwork', + 'hash' => 'aabbccddeeff00112233445566778899', + ]); + + DB::enableQueryLog(); + DB::flushQueryLog(); + $guestPrefetch = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')->assertOk(); + $guestPrefetchQueries = count(DB::getQueryLog()); + + DB::flushQueryLog(); + $guestFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk(); + $guestFullQueries = count(DB::getQueryLog()); + + DB::flushQueryLog(); + $authPrefetch = $this->actingAs(User::factory()->create()) + ->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1') + ->assertOk(); + $authPrefetchQueries = count(DB::getQueryLog()); + DB::flushQueryLog(); + $authFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk(); + $authFullQueries = count(DB::getQueryLog()); + DB::disableQueryLog(); + + fwrite(STDERR, sprintf( + "M18B3 prefetch bytes/queries: guest=%d/%d auth=%d/%d; full guest=%d/%d auth=%d/%d\n", + strlen((string) $guestPrefetch->getContent()), + $guestPrefetchQueries, + strlen((string) $authPrefetch->getContent()), + $authPrefetchQueries, + strlen((string) $guestFull->getContent()), + $guestFullQueries, + strlen((string) $authFull->getContent()), + $authFullQueries, + )); + + expect($guestPrefetchQueries)->toBe(1) + ->and($authPrefetchQueries)->toBeLessThanOrEqual(2) + ->and($guestPrefetch->json())->toEqual($authPrefetch->json()); +}); diff --git a/tests/Feature/Artworks/M18B6ArtworkNavigationTest.php b/tests/Feature/Artworks/M18B6ArtworkNavigationTest.php new file mode 100644 index 00000000..4fbac372 --- /dev/null +++ b/tests/Feature/Artworks/M18B6ArtworkNavigationTest.php @@ -0,0 +1,92 @@ +for($user)->create([ + 'slug' => $slug, + 'published_at' => now()->subDay(), + 'is_public' => true, + 'is_approved' => true, + ]); +} + +it('returns the correct previous and next artwork for a middle item', function (): void { + $user = User::factory()->create(); + $first = m18b6PublishedArtwork($user, 'first'); + $middle = m18b6PublishedArtwork($user, 'middle'); + $last = m18b6PublishedArtwork($user, 'last'); + + $this->getJson('/api/artworks/navigation/'.$middle->id) + ->assertOk() + ->assertJson([ + 'prev_id' => $first->id, + 'next_id' => $last->id, + 'prev_url' => url('/art/'.$first->id.'/first'), + 'next_url' => url('/art/'.$last->id.'/last'), + 'prev_slug' => 'first', + 'next_slug' => 'last', + ]) + ->assertJsonStructure([ + 'prev_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']], + 'next_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']], + ]); +}); + +it('wraps the first item to the last item', function (): void { + $user = User::factory()->create(); + $first = m18b6PublishedArtwork($user, 'first'); + $last = m18b6PublishedArtwork($user, 'last'); + + $this->getJson('/api/artworks/navigation/'.$first->id) + ->assertOk() + ->assertJsonPath('prev_id', $last->id) + ->assertJsonPath('next_id', $last->id) + ->assertJsonPath('prev_preview.id', $last->id) + ->assertJsonPath('next_preview.id', $last->id); +}); + +it('wraps the last item to the first item', function (): void { + $user = User::factory()->create(); + $first = m18b6PublishedArtwork($user, 'first'); + $last = m18b6PublishedArtwork($user, 'last'); + + $this->getJson('/api/artworks/navigation/'.$last->id) + ->assertOk() + ->assertJsonPath('prev_id', $first->id) + ->assertJsonPath('next_id', $first->id) + ->assertJsonPath('prev_preview.id', $first->id) + ->assertJsonPath('next_preview.id', $first->id); +}); + +it('returns the empty contract for a missing or hidden artwork', function (): void { + $empty = [ + 'prev_id' => null, + 'next_id' => null, + 'prev_url' => null, + 'next_url' => null, + 'prev_slug' => null, + 'next_slug' => null, + 'prev_preview' => null, + 'next_preview' => null, + ]; + + $this->getJson('/api/artworks/navigation/999999') + ->assertOk() + ->assertExactJson($empty); + + $private = Artwork::factory()->private()->create(); + $this->getJson('/api/artworks/navigation/'.$private->id) + ->assertOk() + ->assertExactJson($empty); + + $this->actingAs(User::factory()->create()) + ->getJson('/api/artworks/navigation/'.$private->id) + ->assertOk() + ->assertExactJson($empty); +});