Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
cd024f864a
  1. 4
      src/components/Image/index.tsx
  2. 7
      src/components/Note/PublicationCoverFallback.tsx
  3. 50
      src/components/Note/PublicationCoverImage.tsx
  4. 4
      src/components/Note/PublicationIndexMetadata.tsx
  5. 16
      src/lib/gutenberg-cover.test.ts
  6. 17
      src/lib/gutenberg-cover.ts

4
src/components/Image/index.tsx

@ -70,6 +70,8 @@ export default function Image({
className = '', className = '',
classNames = {}, classNames = {},
hideIfError = false, hideIfError = false,
/** Called after internal URL fallbacks are exhausted and the image still failed to load. */
onFinalError,
errorPlaceholder = <ImageOff />, errorPlaceholder = <ImageOff />,
style: wrapperStyleProp, style: wrapperStyleProp,
holdUntilClick = false, holdUntilClick = false,
@ -95,6 +97,7 @@ export default function Image({
/** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */ /** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */
caption?: string caption?: string
hideIfError?: boolean hideIfError?: boolean
onFinalError?: () => void
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */ /** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
fetchPriority?: 'high' | 'low' | 'auto' fetchPriority?: 'high' | 'low' | 'auto'
@ -291,6 +294,7 @@ export default function Image({
setIsLoading(false) setIsLoading(false)
setDisplaySkeleton(false) setDisplaySkeleton(false)
setHasError(true) setHasError(true)
onFinalError?.()
} }
const handleLoad = () => { const handleLoad = () => {

7
src/components/Note/PublicationCoverFallback.tsx

@ -14,14 +14,17 @@ export default function PublicationCoverFallback({
size?: 'library' | 'default' size?: 'library' | 'default'
className?: string className?: string
}) { }) {
const maxClass = size === 'library' ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS const isLibrary = size === 'library'
const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS
const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-48 max-w-full'
return ( return (
<div <div
className={cn( className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground', 'flex shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground',
maxClass, maxClass,
layout === 'stacked' ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]', layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3', layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && size === 'library' && 'mb-2', layout === 'stacked' && size === 'library' && 'mb-2',
className className

50
src/components/Note/PublicationCoverImage.tsx

@ -1,14 +1,15 @@
import { useNearViewport } from '@/hooks/useNearViewport' import { useNearViewport } from '@/hooks/useNearViewport'
import { gutenbergLibraryCoverImageUrl } from '@/lib/gutenberg-cover' import { gutenbergCoverCandidateUrls } from '@/lib/gutenberg-cover'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useRef } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import PublicationCoverFallback from './PublicationCoverFallback'
/** Max cover height in the library grid (3-column cards). */ /** Max cover height in the library grid (3-column cards). */
export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-48' export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-48'
/** Max cover height in publication detail / default cards. */ /** Max cover box in publication detail / note panel (larger axis capped at 400px). */
export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px]' export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px] max-w-[400px]'
export default function PublicationCoverImage({ export default function PublicationCoverImage({
imageUrl, imageUrl,
@ -29,7 +30,33 @@ export default function PublicationCoverImage({
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
const isNearViewport = useNearViewport(wrapperRef, { enabled: isLibrary }) 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 resolvedUrl = isLibrary ? gutenbergLibraryCoverImageUrl(imageUrl) : imageUrl const candidateUrls = useMemo(
() => gutenbergCoverCandidateUrls(imageUrl, isLibrary),
[imageUrl, isLibrary]
)
const [urlIndex, setUrlIndex] = useState(0)
const [exhausted, setExhausted] = useState(false)
const activeUrl = candidateUrls[urlIndex] ?? candidateUrls[0] ?? imageUrl.trim()
useEffect(() => {
setUrlIndex(0)
setExhausted(false)
}, [imageUrl, isLibrary])
const handleImageError = useCallback(() => {
setUrlIndex((index) => {
const next = index + 1
if (next < candidateUrls.length) return next
setExhausted(true)
return index
})
}, [candidateUrls.length])
if (exhausted || !activeUrl) {
return <PublicationCoverFallback layout={layout} size={size} className={className} />
}
const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'w-fit'
return ( return (
<div <div
@ -37,7 +64,7 @@ export default function PublicationCoverImage({
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,
layout === 'stacked' ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]', layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3', layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && isLibrary && 'mb-2', layout === 'stacked' && isLibrary && 'mb-2',
className className
@ -45,10 +72,15 @@ export default function PublicationCoverImage({
> >
{isNearViewport ? ( {isNearViewport ? (
<Image <Image
image={{ url: resolvedUrl, pubkey }} key={activeUrl}
className={cn(maxClass, 'max-w-full object-contain')} image={{ url: activeUrl, pubkey }}
classNames={{ wrapper: 'block w-full max-w-full' }} className={cn(
maxClass,
isLibrary ? 'max-w-full object-contain' : 'h-auto w-auto max-w-full object-contain'
)}
classNames={{ wrapper: isLibrary ? 'block w-full max-w-full' : 'block w-fit max-w-full' }}
hideIfError hideIfError
onFinalError={handleImageError}
holdUntilClick={!autoLoadMedia} holdUntilClick={!autoLoadMedia}
loading={isLibrary ? 'lazy' : 'eager'} loading={isLibrary ? 'lazy' : 'eager'}
fetchPriority={isLibrary ? 'low' : undefined} fetchPriority={isLibrary ? 'low' : undefined}

4
src/components/Note/PublicationIndexMetadata.tsx

@ -128,10 +128,10 @@ export default function PublicationIndexMetadata({
autoLoadMedia={autoLoadMedia} autoLoadMedia={autoLoadMedia}
size="default" size="default"
layout="stacked" layout="stacked"
className="mb-0 w-fit max-w-xl" className="mb-0"
/> />
) : isFull ? ( ) : isFull ? (
<PublicationCoverFallback layout="stacked" size="default" className="w-fit max-w-xl" /> <PublicationCoverFallback layout="stacked" size="default" className="mb-0" />
) : null} ) : null}
{showTitle ? ( {showTitle ? (

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

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
gutenbergCoverCandidateUrls,
gutenbergCoverImageUrl, gutenbergCoverImageUrl,
gutenbergEbookPageUrl, gutenbergEbookPageUrl,
gutenbergLibraryCoverImageUrl, gutenbergLibraryCoverImageUrl,
@ -58,6 +59,21 @@ 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', () => {
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'
])
expect(gutenbergCoverCandidateUrls('https://example.com/cover.jpg', true)).toEqual([
'https://example.com/cover.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'

17
src/lib/gutenberg-cover.ts

@ -67,3 +67,20 @@ export function normalizeGutenbergCoverImageUrl(url: string): string {
if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed
return gutenbergCoverImageUrl(id) return gutenbergCoverImageUrl(id)
} }
/** Ordered cover URLs to try (library prefers small, then medium; always deduped). */
export function gutenbergCoverCandidateUrls(url: string, preferSmall: boolean): string[] {
const trimmed = url.trim()
if (!trimmed) return []
const id = parseGutenbergEbookId(trimmed)
if (!id || !trimmed.toLowerCase().includes('gutenberg')) return [trimmed]
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]) {
if (!ordered.includes(candidate)) ordered.push(candidate)
}
return ordered
}

Loading…
Cancel
Save