Browse Source

render covers

imwald
Silberengel 1 week ago
parent
commit
c7c48e0534
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 37
      src/components/Note/PublicationCoverImage.tsx
  4. 42
      src/lib/gutenberg-cover.test.ts
  5. 44
      src/lib/gutenberg-cover.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.1", "version": "23.21.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.21.1", "version": "23.21.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

37
src/components/Note/PublicationCoverImage.tsx

@ -1,7 +1,6 @@
import { useNearViewport } from '@/hooks/useNearViewport'
import { gutenbergCoverCandidateUrls } from '@/lib/gutenberg-cover' import { gutenbergCoverCandidateUrls } from '@/lib/gutenberg-cover'
import { cn } from '@/lib/utils' 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 Image from '../Image'
import PublicationCoverFallback from './PublicationCoverFallback' import PublicationCoverFallback from './PublicationCoverFallback'
@ -27,8 +26,6 @@ export default function PublicationCoverImage({
className?: string className?: string
}) { }) {
const isLibrary = size === 'library' const isLibrary = size === 'library'
const wrapperRef = useRef<HTMLDivElement>(null)
const isNearViewport = useNearViewport(wrapperRef, { enabled: isLibrary })
const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS
const candidateUrls = useMemo( const candidateUrls = useMemo(
() => gutenbergCoverCandidateUrls(imageUrl, isLibrary), () => gutenbergCoverCandidateUrls(imageUrl, isLibrary),
@ -58,9 +55,12 @@ export default function PublicationCoverImage({
const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'w-fit' 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 ( return (
<div <div
ref={wrapperRef}
className={cn( className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted', 'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted',
maxClass, maxClass,
@ -69,23 +69,18 @@ export default function PublicationCoverImage({
layout === 'stacked' && isLibrary && 'mb-2', layout === 'stacked' && isLibrary && 'mb-2',
className className
)} )}
onClick={holdCoverUntilClick ? (e) => e.stopPropagation() : undefined}
> >
{isNearViewport ? ( <Image
<Image key={activeUrl}
key={activeUrl} image={{ url: activeUrl, pubkey }}
image={{ url: activeUrl, pubkey }} className={cn(maxClass, 'h-auto w-auto max-w-full object-contain')}
className={cn( classNames={{ wrapper: cn('block max-w-full', isLibrary ? 'w-full' : 'w-fit') }}
maxClass, hideIfError
isLibrary ? 'max-w-full object-contain' : 'h-auto w-auto max-w-full object-contain' onFinalError={handleImageError}
)} holdUntilClick={holdCoverUntilClick}
classNames={{ wrapper: isLibrary ? 'block w-full max-w-full' : 'block w-fit max-w-full' }} loading="eager"
hideIfError />
onFinalError={handleImageError}
holdUntilClick={!autoLoadMedia}
loading={isLibrary ? 'lazy' : 'eager'}
fetchPriority={isLibrary ? 'low' : undefined}
/>
) : null}
</div> </div>
) )
} }

42
src/lib/gutenberg-cover.test.ts

@ -22,6 +22,15 @@ describe('gutenberg-cover', () => {
).toBe('16702') ).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', () => { it('builds medium cover URL by default', () => {
expect(gutenbergCoverImageUrl('58363')).toBe( expect(gutenbergCoverImageUrl('58363')).toBe(
'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' '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') 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( expect(
gutenbergCoverCandidateUrls( gutenbergCoverCandidateUrls(
'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg', 'https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg',
true true
) )
).toEqual([ ).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([ expect(gutenbergCoverCandidateUrls('https://example.com/cover.jpg', true)).toEqual([
'https://example.com/cover.jpg' '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', () => { it('normalizeGutenbergCoverImageUrl converts ebook pages to cover JPG', () => {
expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe( expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe(
'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg' 'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg'

44
src/lib/gutenberg-cover.ts

@ -3,6 +3,8 @@
const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i
const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i
const GUTENBERG_CACHE_URL = /gutenberg\.org\/cache\/epub\/(\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`, … */ /** Legacy publication d-tags: `pg28217-dante-et-goethe-dialogues`, `pg28217`, … */
const GUTENBERG_DTAG = /^pg(\d+)(?:-.*)?$/i const GUTENBERG_DTAG = /^pg(\d+)(?:-.*)?$/i
@ -15,6 +17,8 @@ export function parseGutenbergEbookId(source: string): string | null {
const match = trimmed.match(pattern) const match = trimmed.match(pattern)
if (match?.[1]) return match[1] if (match?.[1]) return match[1]
} }
const fromFilename = trimmed.match(PG_COVER_FILENAME)
if (fromFilename?.[1]) return fromFilename[1]
return null return null
} }
@ -61,25 +65,43 @@ export function resolveGutenbergCoverImageUrl(source: string | undefined): strin
*/ */
export function normalizeGutenbergCoverImageUrl(url: string): string { export function normalizeGutenbergCoverImageUrl(url: string): string {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed.toLowerCase().includes('gutenberg')) return trimmed
const id = parseGutenbergEbookId(trimmed) const id = parseGutenbergEbookId(trimmed)
if (!id) return trimmed if (!id) return trimmed
if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed if (trimmed.toLowerCase().includes('gutenberg.org')) {
return gutenbergCoverImageUrl(id) 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() const trimmed = url.trim()
if (!trimmed) return [] if (!trimmed) return []
const id = parseGutenbergEbookId(trimmed) 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[] = [] const ordered = [...canonical]
if (preferSmall) ordered.push(gutenbergCoverImageUrl(id, 'small')) for (const candidate of [normalizeGutenbergCoverImageUrl(trimmed), trimmed]) {
ordered.push(gutenbergCoverImageUrl(id, 'medium'))
const normalized = normalizeGutenbergCoverImageUrl(trimmed)
for (const candidate of [normalized, trimmed]) {
if (!ordered.includes(candidate)) ordered.push(candidate) if (!ordered.includes(candidate)) ordered.push(candidate)
} }
return ordered return ordered

Loading…
Cancel
Save