diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 035008ba..1031d8d6 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -229,19 +229,6 @@ export default function Image({ if (el.complete && el.naturalWidth > 0) { captureIntrinsicDim(el) notifyLoaded() - return - } - if (typeof el.decode === 'function') { - let cancelled = false - el.decode().then(() => { - if (!cancelled && el.naturalWidth > 0) { - captureIntrinsicDim(el) - notifyLoaded() - } - }).catch(() => {}) - return () => { - cancelled = true - } } }, [revealed, badSrc, imageUrl, notifyLoaded, captureIntrinsicDim]) @@ -398,7 +385,7 @@ export default function Image({ src={imageUrl} alt={finalAlt} referrerPolicy="no-referrer-when-downgrade" - decoding={effectiveHoldUntilClick ? 'async' : 'sync'} + decoding="async" // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. loading="eager" {...(fetchPriority ? { fetchpriority: fetchPriority } : {})} diff --git a/src/components/Note/PublicationCoverImage.tsx b/src/components/Note/PublicationCoverImage.tsx index b3e23dd0..a06f127f 100644 --- a/src/components/Note/PublicationCoverImage.tsx +++ b/src/components/Note/PublicationCoverImage.tsx @@ -38,7 +38,7 @@ export default function PublicationCoverImage({ diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index 010df0a4..a14b3571 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -11,10 +11,12 @@ import { type LibraryPublicationEntry, type PublicationEngagementMaps } from '@/lib/library-publication-index' -import { BOOKLIST_LABEL_UPDATED_EVENT } from '@/lib/booklist-label' +import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label' +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' import { getTopLevelIndexEvents } from '@/lib/publication-index' import logger from '@/lib/logger' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import type { Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -39,8 +41,11 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { highlightAddresses: new Set() } +const EMPTY_BOOKLIST_TARGETS = { addresses: new Set(), eventIds: new Set() } + export function useLibraryPublications(isActive: boolean) { const { pubkey, bookmarkListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [entries, setEntries] = useState([]) const [indexEvents, setIndexEvents] = useState([]) const [engagement, setEngagement] = useState(EMPTY_ENGAGEMENT) @@ -56,8 +61,31 @@ export function useLibraryPublications(isActive: boolean) { const [allIndexCount, setAllIndexCount] = useState(0) const [topLevelCount, setTopLevelCount] = useState(0) const [pinListEvent, setPinListEvent] = useState(null) + const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) const loadGenRef = useRef(0) + const loadMyBooklistTargets = useCallback(async () => { + if (!pubkey) { + setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) + return + } + const relays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays: blockedRelays ?? [] + }) + const targets = await fetchViewerBooklistTargets(pubkey, relays) + setMyBooklistTargets(targets) + }, [pubkey, favoriteRelays, blockedRelays]) + + useEffect(() => { + if (!isActive || !pubkey) { + setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) + return + } + void loadMyBooklistTargets() + }, [isActive, pubkey, loadMyBooklistTargets]) + useEffect(() => { if (!pubkey) { setPinListEvent(null) @@ -65,9 +93,9 @@ export function useLibraryPublications(isActive: boolean) { } let cancelled = false void (async () => { - const relays = await buildLibraryRelayUrls(pubkey) + const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) const pinList = await fetchNewestPinListForPubkey(pubkey, relays) - if (!cancelled) setPinListEvent(pinList) + if (!cancelled) setPinListEvent(pinList ?? null) })() return () => { cancelled = true @@ -89,7 +117,7 @@ export function useLibraryPublications(isActive: boolean) { logger.info('[Library] page load requested', { forceRefresh, gen }) } try { - const relays = await buildLibraryRelayUrls(pubkey || undefined) + const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) let timeoutId: number | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error('Library load timed out')), LOAD_TIMEOUT_MS) @@ -134,7 +162,7 @@ export function useLibraryPublications(isActive: boolean) { } } }, - [pubkey] + [pubkey, blockedRelays] ) useEffect(() => { @@ -147,7 +175,8 @@ export function useLibraryPublications(isActive: boolean) { let cancelled = false const onBooklistUpdated = () => { void (async () => { - const relays = await buildLibraryRelayUrls(pubkey) + await loadMyBooklistTargets() + const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement( relays, indexEvents, @@ -165,7 +194,7 @@ export function useLibraryPublications(isActive: boolean) { cancelled = true window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) } - }, [isActive, pubkey, indexEvents, debouncedSearch]) + }, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays]) useEffect(() => { const q = debouncedSearch.trim() @@ -205,8 +234,8 @@ export function useLibraryPublications(isActive: boolean) { setRelaySearchLoading(true) setError(null) try { - const relays = await buildLibraryRelayUrls(pubkey || undefined) - const { events, mergedIndexEvents, entries, fromCache } = await searchLibraryPublicationsOnRelays( + const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) + const { events, mergedIndexEvents, fromCache } = await searchLibraryPublicationsOnRelays( q, relays, { indexEvents, engagement } @@ -220,6 +249,18 @@ export function useLibraryPublications(isActive: boolean) { fromCache }) } + + let nextEngagement = engagement + if (pubkey) { + const refreshed = await refreshLibraryEngagement(relays, mergedIndexEvents, pubkey) + nextEngagement = refreshed.engagement + setEngagement(nextEngagement) + } + + const entries = await searchLibraryPublications(q, { + indexEvents: mergedIndexEvents, + engagement: nextEngagement + }) setSearchResults(entries) } catch (e) { const message = e instanceof Error ? e.message : 'Relay search failed' @@ -230,11 +271,16 @@ export function useLibraryPublications(isActive: boolean) { } finally { setRelaySearchLoading(false) } - }, [searchQuery, pubkey, indexEvents, engagement]) + }, [searchQuery, pubkey, indexEvents, engagement, blockedRelays]) const filteredEntries = useMemo(() => { const q = debouncedSearch.trim() - const mineFilterOpts = { bookmarkListEvent, pinListEvent } + const mineFilterOpts = { + bookmarkListEvent, + pinListEvent, + myBooklistAddresses: myBooklistTargets.addresses, + myBooklistEventIds: myBooklistTargets.eventIds + } let list: LibraryPublicationEntry[] if (showOnlyMine && !q) { list = filterLibraryPublicationsByUser( @@ -258,7 +304,8 @@ export function useLibraryPublications(isActive: boolean) { indexEvents, engagement, bookmarkListEvent, - pinListEvent + pinListEvent, + myBooklistTargets ]) return { diff --git a/src/lib/booklist-label.ts b/src/lib/booklist-label.ts index fa842394..c6a46fef 100644 --- a/src/lib/booklist-label.ts +++ b/src/lib/booklist-label.ts @@ -9,6 +9,57 @@ import type { Event, Filter } from 'nostr-tools' export const BOOKLIST_LABEL_UPDATED_EVENT = 'booklist-label-updated' +export type ViewerBooklistTargets = { + addresses: Set + eventIds: Set +} + +function collectBooklistTargetsFromLabelEvents(events: Event[]): ViewerBooklistTargets { + const addresses = new Set() + const eventIds = new Set() + for (const ev of events) { + if (!labelEventHasBooklistTag(ev)) continue + for (const tag of ev.tags) { + if (tag[0] === 'a' && tag[1]?.trim()) addresses.add(tag[1].trim()) + if (tag[0] === 'e' && tag[1]?.trim()) eventIds.add(tag[1].trim().toLowerCase()) + } + } + return { addresses, eventIds } +} + +/** All publication coordinates the viewer has on their booklist (session + network). */ +export async function fetchViewerBooklistTargets( + userPubkey: string, + relayUrls: string[] +): Promise { + const sessionHits = eventService.listSessionEventsAuthoredBy(userPubkey, { + kinds: [ExtendedKind.LABEL], + limit: 200 + }) + const merged = new Map() + for (const ev of sessionHits) { + if (labelEventHasBooklistTag(ev)) merged.set(ev.id, ev) + } + + if (relayUrls.length > 0) { + const filter: Filter = { + kinds: [ExtendedKind.LABEL], + authors: [userPubkey], + '#l': [NIP32_BOOKLIST_LABEL], + limit: 500 + } + const network = await client.fetchEvents(relayUrls, [filter], { + globalTimeout: 12_000, + eoseTimeout: 3_000 + }) + for (const ev of network) { + if (labelEventHasBooklistTag(ev)) merged.set(ev.id, ev) + } + } + + return collectBooklistTargetsFromLabelEvents([...merged.values()]) +} + export function dispatchBooklistLabelUpdated(publication: Event): void { window.dispatchEvent( new CustomEvent(BOOKLIST_LABEL_UPDATED_EVENT, { diff --git a/src/lib/event-metadata.publication-index.test.ts b/src/lib/event-metadata.publication-index.test.ts index 0cf0de4f..e93a4c9b 100644 --- a/src/lib/event-metadata.publication-index.test.ts +++ b/src/lib/event-metadata.publication-index.test.ts @@ -85,4 +85,15 @@ describe('getPublicationIndexMetadataFromEvent', () => { const meta = getPublicationIndexMetadataFromEvent(event) expect(meta.image).toBe('https://example.com/cover.jpg') }) + + it('normalizes Gutenberg ebook page in image tag to cover JPG', () => { + const event = indexEvent([ + ['d', 'book'], + ['title', 'Book'], + ['image', 'https://www.gutenberg.org/ebooks/16702'], + ['a', `30041:${PK}:intro`] + ]) + const meta = getPublicationIndexMetadataFromEvent(event) + expect(meta.image).toBe('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg') + }) }) diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 8cb67790..37b480dd 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -2,7 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE } import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { Event, kinds } from 'nostr-tools' import { buildATag } from './draft-event' -import { resolveGutenbergCoverImageUrl } from './gutenberg-cover' +import { normalizeGutenbergCoverImageUrl, resolveGutenbergCoverImageUrl } from './gutenberg-cover' import { getLatestEvent, getReplaceableEventIdentifier } from './event' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey, pubkeyToNpub } from './pubkey' @@ -720,7 +720,9 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI } let image = base.image?.trim() || undefined - if (!image) { + if (image) { + image = normalizeGutenbergCoverImageUrl(image) + } else { image = resolveGutenbergCoverImageUrl(source) } diff --git a/src/lib/gutenberg-cover.test.ts b/src/lib/gutenberg-cover.test.ts index cb9ae631..3773a332 100644 --- a/src/lib/gutenberg-cover.test.ts +++ b/src/lib/gutenberg-cover.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { gutenbergCoverImageUrl, + normalizeGutenbergCoverImageUrl, parseGutenbergEbookId, resolveGutenbergCoverImageUrl } from '@/lib/gutenberg-cover' @@ -12,6 +13,9 @@ describe('gutenberg-cover', () => { expect(parseGutenbergEbookId('https://www.gutenberg.org/files/21020/21020-h/21020-h.htm')).toBe( '21020' ) + expect( + parseGutenbergEbookId('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg') + ).toBe('16702') }) it('builds medium cover URL', () => { @@ -26,4 +30,18 @@ describe('gutenberg-cover', () => { ).toBe('https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg') expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined() }) + + it('normalizeGutenbergCoverImageUrl converts ebook pages to cover JPG', () => { + expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe( + 'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg' + ) + expect( + normalizeGutenbergCoverImageUrl( + 'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg' + ) + ).toBe('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg') + expect(normalizeGutenbergCoverImageUrl('https://example.com/cover.jpg')).toBe( + 'https://example.com/cover.jpg' + ) + }) }) diff --git a/src/lib/gutenberg-cover.ts b/src/lib/gutenberg-cover.ts index 48dd3b8f..43077cd3 100644 --- a/src/lib/gutenberg-cover.ts +++ b/src/lib/gutenberg-cover.ts @@ -2,11 +2,14 @@ const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i +const GUTENBERG_CACHE_URL = /gutenberg\.org\/cache\/epub\/(\d+)/i + +const DIRECT_IMAGE_EXT = /\.(?:jpe?g|png|gif|webp|avif)(?:[?#]|$)/i export function parseGutenbergEbookId(source: string): string | null { const trimmed = source.trim() if (!trimmed) return null - for (const pattern of [GUTENBERG_EBOOK_URL, GUTENBERG_FILES_URL]) { + for (const pattern of [GUTENBERG_EBOOK_URL, GUTENBERG_FILES_URL, GUTENBERG_CACHE_URL]) { const match = trimmed.match(pattern) if (match?.[1]) return match[1] } @@ -26,3 +29,16 @@ export function resolveGutenbergCoverImageUrl(source: string | undefined): strin if (!id) return undefined return gutenbergCoverImageUrl(id) } + +/** + * Normalize a publication `image` tag URL. Gutenberg ebook/files pages become cache cover JPGs; + * direct `.jpg` / cache cover URLs are kept as-is. + */ +export function normalizeGutenbergCoverImageUrl(url: string): string { + const trimmed = url.trim() + if (!trimmed.toLowerCase().includes('gutenberg')) return trimmed + const id = parseGutenbergEbookId(trimmed) + if (!id) return trimmed + if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed + return gutenbergCoverImageUrl(id) +} diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index e2f65de6..ceef187b 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -338,4 +338,48 @@ describe('library-publication-index', () => { [authored.id, booklisted.id, commented.id, unrelated.id].sort() ) }) + + it('searchLibraryPublications keeps my booklist flags for booklist-only publications', async () => { + clearLibrarySearchSessionCache() + const viewerPk = 'f'.repeat(64) + const rootAddr = `30040:${PK}:jane-eyre` + const root = indexEvent('jane-eyre', [`30041:${PK}:intro`], '9'.repeat(64)) + root.tags = [['d', 'jane-eyre'], ['title', 'Jane Eyre'], ['a', `30041:${PK}:intro`]] + const label: Event = { + id: '7'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: viewerPk, + created_at: 50, + content: '', + tags: [['L', 'ugc'], ['l', 'booklist', 'ugc'], ['a', rootAddr]], + sig: 'e'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([label], [], [], undefined, undefined, viewerPk) + const results = await searchLibraryPublications('jane eyre', { indexEvents: [root], engagement }) + expect(results).toHaveLength(1) + expect(results[0].hasMyBooklistLabel).toBe(true) + expect(filterLibraryPublicationsByUser(results, viewerPk)).toHaveLength(1) + }) + + it('filterLibraryPublicationsByUser matches myBooklistAddresses without engagement flags', () => { + const viewerPk = 'f'.repeat(64) + const rootAddr = `30040:${PK}:jane-eyre` + const root = indexEvent('jane-eyre', [`30041:${PK}:intro`], '9'.repeat(64)) + const entry = { + event: root, + hasLabel: false, + labelNames: [], + hasBooklistLabel: false, + hasMyBooklistLabel: false, + hasMyComment: false, + hasMyHighlight: false, + hasComment: false, + hasHighlight: false, + engagementCount: 0 + } + const filtered = filterLibraryPublicationsByUser([entry], viewerPk, { + myBooklistAddresses: new Set([rootAddr]) + }) + expect(filtered).toHaveLength(1) + }) }) diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index aa1484bf..0eaf5f10 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -1,3 +1,4 @@ +import { findSessionBooklistLabelForPublication } from '@/lib/booklist-label' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { eventMatchesGeneralSearchQuery, @@ -21,6 +22,7 @@ import { } from '@/lib/publication-index' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { isEventInPinList } from '@/lib/replaceable-list-latest' +import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { clearLibraryIndexIdbCache, @@ -117,7 +119,15 @@ function librarySearchFingerprint(context: LibrarySearchContext): string { ? engagement.labelAddresses.size + engagement.labelEventIds.size + engagement.commentAddresses.size + - engagement.highlightAddresses.size + engagement.highlightAddresses.size + + engagement.booklistAddresses.size + + engagement.booklistEventIds.size + + engagement.myBooklistAddresses.size + + engagement.myBooklistEventIds.size + + engagement.myCommentAddresses.size + + engagement.myCommentEventIds.size + + engagement.myHighlightAddresses.size + + engagement.myHighlightEventIds.size : 0 return `${context.indexEvents.length}:${engagementSize}` } @@ -247,23 +257,38 @@ function normalizeLibraryRelayUrl(url: string): string { return normalizeUrl(trimmed) || trimmed } -function libraryIndexRelayUrls(extraRelayUrls: string[] = []): string[] { - const base = LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean) - const extra = extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean) +function filterBlockedLibraryRelays(urls: string[], blockedRelays: readonly string[] = []): string[] { + if (blockedRelays.length === 0) return urls + return urls.filter((url) => !isRelayBlockedByUser(url, blockedRelays)) +} + +function libraryIndexRelayUrls(extraRelayUrls: string[] = [], blockedRelays: readonly string[] = []): string[] { + const base = filterBlockedLibraryRelays( + LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean), + blockedRelays + ) + const extra = filterBlockedLibraryRelays( + extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean), + blockedRelays + ) return [...new Set([...base, ...extra])] } -export async function buildLibraryRelayUrls(userPubkey?: string): Promise { - const base = libraryIndexRelayUrls() +export async function buildLibraryRelayUrls( + userPubkey?: string, + blockedRelays: string[] = [] +): Promise { + const base = libraryIndexRelayUrls([], blockedRelays) const urls = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeFastReadRelays: false, includeSearchableRelays: false, includeFavoriteRelays: false, - relayHints: base + relayHints: base, + blockedRelays }) - return libraryIndexRelayUrls([...urls]) + return libraryIndexRelayUrls([...urls], blockedRelays) } export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise { @@ -733,13 +758,19 @@ export function publicationEntryBelongsToUser( userPubkey: string bookmarkListEvent?: Event | null pinListEvent?: Event | null + myBooklistAddresses?: Set + myBooklistEventIds?: Set } ): boolean { const { event } = entry const pk = opts.userPubkey.toLowerCase() + const rootAddr = eventTagAddress(event) if (event.pubkey.toLowerCase() === pk) return true if (event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true + if (rootAddr && opts.myBooklistAddresses?.has(rootAddr)) return true + if (opts.myBooklistEventIds?.has(event.id.toLowerCase())) return true + if (findSessionBooklistLabelForPublication(opts.userPubkey, event)) return true if (opts.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, event)) return true if (opts.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true return false @@ -806,20 +837,7 @@ function libraryEntriesFromRoots( indexByAddress: Map, engagement: PublicationEngagementMaps ): LibraryPublicationEntry[] { - return roots.map((root) => { - const engaged = filterEngagedPublications([root], indexByAddress, engagement) - if (engaged.length > 0) return engaged[0] - return { - event: root, - hasLabel: false, - labelNames: [], - hasBooklistLabel: false, - hasMyBooklistLabel: false, - hasComment: false, - hasHighlight: false, - engagementCount: 0 - } - }) + return roots.map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) } /** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */ @@ -1213,6 +1231,8 @@ export function filterLibraryPublicationsByUser( opts?: { bookmarkListEvent?: Event | null pinListEvent?: Event | null + myBooklistAddresses?: Set + myBooklistEventIds?: Set } ): LibraryPublicationEntry[] { if (!userPubkey) return entries @@ -1220,7 +1240,9 @@ export function filterLibraryPublicationsByUser( publicationEntryBelongsToUser(entry, { userPubkey, bookmarkListEvent: opts?.bookmarkListEvent, - pinListEvent: opts?.pinListEvent + pinListEvent: opts?.pinListEvent, + myBooklistAddresses: opts?.myBooklistAddresses, + myBooklistEventIds: opts?.myBooklistEventIds }) ) } diff --git a/vite.config.ts b/vite.config.ts index 67dd7809..c7333369 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -527,6 +527,12 @@ export default defineConfig(({ mode }) => { cacheableResponse: { statuses: [200] } } }, + { + // Project Gutenberg covers: bypass SW cache — CacheFirst can serve stale/truncated + // HTML error bodies for .jpg URLs and the browser reports "image corrupt or truncated". + urlPattern: /^https:\/\/(?:www\.)?gutenberg\.org\//i, + handler: 'NetworkOnly' + }, { // Generic cross-origin images by file extension (covers hosts not matched above) urlPattern: /^https?:\/\/.+\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)(?:\?.*)?$/i,