From c7c48e0534817ed0fda6399f8bd0b0424385c398 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 20:59:29 +0200 Subject: [PATCH] render covers --- package-lock.json | 4 +- package.json | 2 +- src/components/Note/PublicationCoverImage.tsx | 37 +++++++--------- src/lib/gutenberg-cover.test.ts | 42 ++++++++++++++++-- src/lib/gutenberg-cover.ts | 44 ++++++++++++++----- 5 files changed, 91 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e49c83c..a01c3cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.21.1", + "version": "23.21.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.21.1", + "version": "23.21.2", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 628f01a5..c73c46c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.21.1", + "version": "23.21.2", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Note/PublicationCoverImage.tsx b/src/components/Note/PublicationCoverImage.tsx index cfd91ac3..53afddea 100644 --- a/src/components/Note/PublicationCoverImage.tsx +++ b/src/components/Note/PublicationCoverImage.tsx @@ -1,7 +1,6 @@ -import { useNearViewport } from '@/hooks/useNearViewport' import { gutenbergCoverCandidateUrls } from '@/lib/gutenberg-cover' import { cn } from '@/lib/utils' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Image from '../Image' import PublicationCoverFallback from './PublicationCoverFallback' @@ -27,8 +26,6 @@ export default function PublicationCoverImage({ className?: string }) { const isLibrary = size === 'library' - const wrapperRef = useRef(null) - const isNearViewport = useNearViewport(wrapperRef, { enabled: isLibrary }) const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS const candidateUrls = useMemo( () => gutenbergCoverCandidateUrls(imageUrl, isLibrary), @@ -58,9 +55,12 @@ export default function PublicationCoverImage({ const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'w-fit' + // Library grid: always load covers (user opened Bibliothek). Tap-to-reveal on the card would + // fight PublicationCard navigation, leaving blurhash placeholders stuck forever. + const holdCoverUntilClick = isLibrary ? false : !autoLoadMedia + return (
e.stopPropagation() : undefined} > - {isNearViewport ? ( - - ) : null} +
) } diff --git a/src/lib/gutenberg-cover.test.ts b/src/lib/gutenberg-cover.test.ts index 51854166..4aa894b0 100644 --- a/src/lib/gutenberg-cover.test.ts +++ b/src/lib/gutenberg-cover.test.ts @@ -22,6 +22,15 @@ describe('gutenberg-cover', () => { ).toBe('16702') }) + it('parses ebook id from pg-prefixed cover filenames on third-party hosts', () => { + expect( + parseGutenbergEbookId( + 'https://api.nostr.build/v2/upload/67104/p/33358/pg33358.cover.medium.jpg' + ) + ).toBe('33358') + expect(parseGutenbergEbookId('https://cdn.example.com/pg292405.jpg')).toBe('292405') + }) + it('builds medium cover URL by default', () => { expect(gutenbergCoverImageUrl('58363')).toBe( 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' @@ -59,21 +68,48 @@ describe('gutenberg-cover', () => { expect(gutenbergEbookPageUrl('28217')).toBe('https://www.gutenberg.org/ebooks/28217') }) - it('gutenbergCoverCandidateUrls tries small then medium in library mode', () => { + it('gutenbergCoverCandidateUrls tries medium first, then small in library mode', () => { expect( gutenbergCoverCandidateUrls( 'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg', true ) ).toEqual([ - 'https://www.gutenberg.org/cache/epub/11/pg11.cover.small.jpg', - 'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg' + 'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg', + 'https://www.gutenberg.org/cache/epub/11/pg11.cover.small.jpg' ]) + expect( + gutenbergCoverCandidateUrls( + 'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg', + false + ) + ).toEqual(['https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg']) expect(gutenbergCoverCandidateUrls('https://example.com/cover.jpg', true)).toEqual([ 'https://example.com/cover.jpg' ]) }) + it('gutenbergCoverCandidateUrls falls back to gutenberg.org for PG mirror URLs', () => { + expect( + gutenbergCoverCandidateUrls( + 'https://api.nostr.build/v2/upload/67104/p/33358/pg33358.cover.medium.jpg', + true + ) + ).toEqual([ + 'https://api.nostr.build/v2/upload/67104/p/33358/pg33358.cover.medium.jpg', + 'https://www.gutenberg.org/cache/epub/33358/pg33358.cover.medium.jpg', + 'https://www.gutenberg.org/cache/epub/33358/pg33358.cover.small.jpg' + ]) + }) + + it('normalizeGutenbergCoverImageUrl rewrites PG mirror filenames to gutenberg.org', () => { + expect( + normalizeGutenbergCoverImageUrl( + 'https://api.nostr.build/v2/upload/67104/p/33358/pg33358.cover.medium.jpg' + ) + ).toBe('https://www.gutenberg.org/cache/epub/33358/pg33358.cover.medium.jpg') + }) + 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' diff --git a/src/lib/gutenberg-cover.ts b/src/lib/gutenberg-cover.ts index 7ad36333..067acc1c 100644 --- a/src/lib/gutenberg-cover.ts +++ b/src/lib/gutenberg-cover.ts @@ -3,6 +3,8 @@ 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 +/** `pg63983.cover.medium.jpg`, `pg292405.jpg`, … on any host (e.g. nostr.build mirrors). */ +const PG_COVER_FILENAME = /[/\s]pg(\d+)(?:\.cover\.(?:small|medium)\.jpg|\.jpg)/i /** Legacy publication d-tags: `pg28217-dante-et-goethe-dialogues`, `pg28217`, … */ const GUTENBERG_DTAG = /^pg(\d+)(?:-.*)?$/i @@ -15,6 +17,8 @@ export function parseGutenbergEbookId(source: string): string | null { const match = trimmed.match(pattern) if (match?.[1]) return match[1] } + const fromFilename = trimmed.match(PG_COVER_FILENAME) + if (fromFilename?.[1]) return fromFilename[1] return null } @@ -61,25 +65,43 @@ export function resolveGutenbergCoverImageUrl(source: string | undefined): strin */ 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) + if (trimmed.toLowerCase().includes('gutenberg.org')) { + if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed + return gutenbergCoverImageUrl(id) + } + // Third-party PG mirror filename — canonical Gutenberg CDN cover. + if (PG_COVER_FILENAME.test(trimmed)) return gutenbergCoverImageUrl(id) + if (trimmed.toLowerCase().includes('gutenberg')) return gutenbergCoverImageUrl(id) + return trimmed } -/** Ordered cover URLs to try (library prefers small, then medium; always deduped). */ -export function gutenbergCoverCandidateUrls(url: string, preferSmall: boolean): string[] { +/** + * Ordered cover URLs to try. Medium is always first (matches detail panel); library may also try small. + * Non-gutenberg.org mirrors (e.g. nostr.build) keep the original URL, then fall back to gutenberg.org. + */ +export function gutenbergCoverCandidateUrls(url: string, includeSmallFallback: boolean): string[] { const trimmed = url.trim() if (!trimmed) return [] const id = parseGutenbergEbookId(trimmed) - if (!id || !trimmed.toLowerCase().includes('gutenberg')) return [trimmed] + if (!id) return [trimmed] + + const canonical: string[] = [ + gutenbergCoverImageUrl(id, 'medium'), + ...(includeSmallFallback ? [gutenbergCoverImageUrl(id, 'small')] : []) + ] + + if (!trimmed.toLowerCase().includes('gutenberg.org')) { + const ordered = [trimmed] + for (const candidate of canonical) { + if (!ordered.includes(candidate)) ordered.push(candidate) + } + return ordered + } - const ordered: string[] = [] - if (preferSmall) ordered.push(gutenbergCoverImageUrl(id, 'small')) - ordered.push(gutenbergCoverImageUrl(id, 'medium')) - const normalized = normalizeGutenbergCoverImageUrl(trimmed) - for (const candidate of [normalized, trimmed]) { + const ordered = [...canonical] + for (const candidate of [normalizeGutenbergCoverImageUrl(trimmed), trimmed]) { if (!ordered.includes(candidate)) ordered.push(candidate) } return ordered