forked from Archives/Athou_commafeed
show placeholders for loading img tags, this allows the entry to have its final height immediately
This commit is contained in:
34
commafeed-client/package-lock.json
generated
34
commafeed-client/package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"@reduxjs/toolkit": "^1.8.4",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.5",
|
||||
"interweave": "^13.0.0",
|
||||
"make-plural": "^7.1.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
@@ -4583,6 +4584,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
@@ -6114,6 +6120,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/interweave": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
|
||||
"integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
|
||||
"dependencies": {
|
||||
"escape-html": "^1.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/milesjohnson"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -12886,6 +12907,11 @@
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
|
||||
},
|
||||
"escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
@@ -13992,6 +14018,14 @@
|
||||
"side-channel": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"interweave": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
|
||||
"integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
|
||||
"requires": {
|
||||
"escape-html": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@reduxjs/toolkit": "^1.8.4",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.5",
|
||||
"interweave": "^13.0.0",
|
||||
"make-plural": "^7.1.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -89,6 +89,7 @@ export const Constants = {
|
||||
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
|
||||
headerHeight: 60,
|
||||
sidebarWidth: 350,
|
||||
entryMaxWidth: 650,
|
||||
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
|
||||
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
|
||||
},
|
||||
|
||||
@@ -19,3 +19,9 @@ export function categoryUnreadCount(category?: Category): number {
|
||||
.map(f => f.unread)
|
||||
.reduce((total, current) => total + current, 0)
|
||||
}
|
||||
|
||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||
return { width: placeholderWidth, height: placeholderHeight }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Center, createStyles } from "@mantine/core"
|
||||
import { useState } from "react"
|
||||
import { TbPhoto } from "react-icons/tb"
|
||||
|
||||
interface ImageWithPlaceholderWhileLoadingProps {
|
||||
src?: string
|
||||
alt?: string
|
||||
title?: string
|
||||
width?: number
|
||||
height?: number | "auto"
|
||||
placeholderWidth?: number
|
||||
placeholderHeight?: number
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, props: ImageWithPlaceholderWhileLoadingProps) => ({
|
||||
placeholder: {
|
||||
width: props.placeholderWidth ?? 400,
|
||||
height: props.placeholderHeight ?? 600,
|
||||
maxWidth: "100%",
|
||||
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
||||
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
},
|
||||
}))
|
||||
|
||||
export function ImageWithPlaceholderWhileLoading(props: ImageWithPlaceholderWhileLoadingProps) {
|
||||
const { classes } = useStyles(props)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Box>
|
||||
<Center className={classes.placeholder}>
|
||||
<div>
|
||||
<TbPhoto size={48} />
|
||||
</div>
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.alt}
|
||||
title={props.title}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
onLoad={() => setLoading(false)}
|
||||
style={{ display: loading ? "none" : "block" }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createStyles, Text, TypographyStylesProvider } from "@mantine/core"
|
||||
import { Box, createStyles, TypographyStylesProvider } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { Interweave, TransformCallback } from "interweave"
|
||||
|
||||
export interface ContentProps {
|
||||
content: string
|
||||
@@ -11,10 +15,6 @@ const useStyles = createStyles(theme => ({
|
||||
"& a": {
|
||||
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
||||
},
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
},
|
||||
"& iframe": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
@@ -24,11 +24,42 @@ const useStyles = createStyles(theme => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const transform: TransformCallback = node => {
|
||||
if (node.tagName === "IMG") {
|
||||
// show placeholders for loading img tags, this allows the entry to have its final height immediately
|
||||
const src = node.getAttribute("src") ?? undefined
|
||||
const alt = node.getAttribute("alt") ?? undefined
|
||||
const title = node.getAttribute("title") ?? undefined
|
||||
const width = node.getAttribute("width") ? parseInt(node.getAttribute("width")!, 10) : undefined
|
||||
const height = node.getAttribute("height") ? parseInt(node.getAttribute("height")!, 10) : undefined
|
||||
const placeholderSize = calculatePlaceholderSize({
|
||||
width,
|
||||
height,
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
})
|
||||
return (
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title}
|
||||
width={width}
|
||||
height="auto"
|
||||
placeholderWidth={placeholderSize.width}
|
||||
placeholderHeight={placeholderSize.height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function Content(props: ContentProps) {
|
||||
const { classes } = useStyles()
|
||||
|
||||
return (
|
||||
<TypographyStylesProvider>
|
||||
<Text size="md" className={classes.content} dangerouslySetInnerHTML={{ __html: props.content }} />
|
||||
<Box className={classes.content}>
|
||||
<Interweave content={props.content} transform={transform} />
|
||||
</Box>
|
||||
</TypographyStylesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { createStyles } from "@mantine/core"
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
enclosureImage: {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
},
|
||||
}))
|
||||
import { TypographyStylesProvider } from "@mantine/core"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string }) {
|
||||
const { classes } = useStyles()
|
||||
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
|
||||
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
|
||||
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<TypographyStylesProvider>
|
||||
{hasVideo && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video controls>
|
||||
@@ -26,7 +20,7 @@ export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</audio>
|
||||
)}
|
||||
{hasImage && <img src={props.enclosureUrl} alt="enclosure" className={classes.enclosureImage} />}
|
||||
</>
|
||||
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
|
||||
</TypographyStylesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const useStyles = createStyles((theme, props: FeedEntryProps) => {
|
||||
},
|
||||
},
|
||||
body: {
|
||||
maxWidth: "650px",
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Box, createStyles } from "@mantine/core"
|
||||
import { Box, TypographyStylesProvider } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { Content } from "./Content"
|
||||
|
||||
export interface MediaProps {
|
||||
@@ -8,29 +11,29 @@ export interface MediaProps {
|
||||
description?: string
|
||||
}
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
image: {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
},
|
||||
}))
|
||||
|
||||
export function Media(props: MediaProps) {
|
||||
const { classes } = useStyles()
|
||||
const width = props.thumbnailWidth
|
||||
const height = props.thumbnailHeight
|
||||
const placeholderSize = calculatePlaceholderSize({
|
||||
width,
|
||||
height,
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
className={classes.image}
|
||||
<TypographyStylesProvider>
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={props.thumbnailUrl}
|
||||
alt="media thumbnail"
|
||||
width={props.thumbnailWidth}
|
||||
height={props.thumbnailHeight}
|
||||
alt="media thumbnail"
|
||||
placeholderWidth={placeholderSize.width}
|
||||
placeholderHeight={placeholderSize.height}
|
||||
/>
|
||||
{props.description && (
|
||||
<Box pt="md">
|
||||
<Content content={props.description} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</TypographyStylesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ app:
|
||||
|
||||
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
|
||||
# useful if commafeed is usually accessed through a restricting proxy
|
||||
imageProxyEnabled: false
|
||||
imageProxyEnabled: true
|
||||
|
||||
# database query timeout (in milliseconds), 0 to disable
|
||||
queryTimeout: 0
|
||||
|
||||
Reference in New Issue
Block a user