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",
|
"@reduxjs/toolkit": "^1.8.4",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"dayjs": "^1.11.5",
|
"dayjs": "^1.11.5",
|
||||||
|
"interweave": "^13.0.0",
|
||||||
"make-plural": "^7.1.0",
|
"make-plural": "^7.1.0",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -4583,6 +4584,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
@@ -6114,6 +6120,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||||
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
|
"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": {
|
"escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
@@ -13992,6 +14018,14 @@
|
|||||||
"side-channel": "^1.0.4"
|
"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": {
|
"invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@reduxjs/toolkit": "^1.8.4",
|
"@reduxjs/toolkit": "^1.8.4",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"dayjs": "^1.11.5",
|
"dayjs": "^1.11.5",
|
||||||
|
"interweave": "^13.0.0",
|
||||||
"make-plural": "^7.1.0",
|
"make-plural": "^7.1.0",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const Constants = {
|
|||||||
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
|
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
|
||||||
headerHeight: 60,
|
headerHeight: 60,
|
||||||
sidebarWidth: 350,
|
sidebarWidth: 350,
|
||||||
|
entryMaxWidth: 650,
|
||||||
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
|
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
|
||||||
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
|
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ export function categoryUnreadCount(category?: Category): number {
|
|||||||
.map(f => f.unread)
|
.map(f => f.unread)
|
||||||
.reduce((total, current) => total + current, 0)
|
.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 {
|
export interface ContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -11,10 +15,6 @@ const useStyles = createStyles(theme => ({
|
|||||||
"& a": {
|
"& a": {
|
||||||
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
|
||||||
},
|
},
|
||||||
"& img": {
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
},
|
|
||||||
"& iframe": {
|
"& iframe": {
|
||||||
maxWidth: "100%",
|
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) {
|
export function Content(props: ContentProps) {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypographyStylesProvider>
|
<TypographyStylesProvider>
|
||||||
<Text size="md" className={classes.content} dangerouslySetInnerHTML={{ __html: props.content }} />
|
<Box className={classes.content}>
|
||||||
|
<Interweave content={props.content} transform={transform} />
|
||||||
|
</Box>
|
||||||
</TypographyStylesProvider>
|
</TypographyStylesProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { createStyles } from "@mantine/core"
|
import { TypographyStylesProvider } from "@mantine/core"
|
||||||
|
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||||
const useStyles = createStyles(() => ({
|
|
||||||
enclosureImage: {
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string }) {
|
export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string }) {
|
||||||
const { classes } = useStyles()
|
|
||||||
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
|
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
|
||||||
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
|
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
|
||||||
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
|
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TypographyStylesProvider>
|
||||||
{hasVideo && (
|
{hasVideo && (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<video controls>
|
<video controls>
|
||||||
@@ -26,7 +20,7 @@ export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string
|
|||||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||||
</audio>
|
</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: {
|
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"
|
import { Content } from "./Content"
|
||||||
|
|
||||||
export interface MediaProps {
|
export interface MediaProps {
|
||||||
@@ -8,29 +11,29 @@ export interface MediaProps {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = createStyles(() => ({
|
|
||||||
image: {
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
export function Media(props: MediaProps) {
|
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 (
|
return (
|
||||||
<>
|
<TypographyStylesProvider>
|
||||||
<img
|
<ImageWithPlaceholderWhileLoading
|
||||||
className={classes.image}
|
|
||||||
src={props.thumbnailUrl}
|
src={props.thumbnailUrl}
|
||||||
|
alt="media thumbnail"
|
||||||
width={props.thumbnailWidth}
|
width={props.thumbnailWidth}
|
||||||
height={props.thumbnailHeight}
|
height={props.thumbnailHeight}
|
||||||
alt="media thumbnail"
|
placeholderWidth={placeholderSize.width}
|
||||||
|
placeholderHeight={placeholderSize.height}
|
||||||
/>
|
/>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<Box pt="md">
|
<Box pt="md">
|
||||||
<Content content={props.description} />
|
<Content content={props.description} />
|
||||||
</Box>
|
</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
|
# 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
|
# useful if commafeed is usually accessed through a restricting proxy
|
||||||
imageProxyEnabled: false
|
imageProxyEnabled: true
|
||||||
|
|
||||||
# database query timeout (in milliseconds), 0 to disable
|
# database query timeout (in milliseconds), 0 to disable
|
||||||
queryTimeout: 0
|
queryTimeout: 0
|
||||||
|
|||||||
Reference in New Issue
Block a user