feat: pictures masonry (#212)
* feat: masonry Signed-off-by: Innei <i@innei.in> * fix: layout Signed-off-by: Innei <i@innei.in> * refactor Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * proxy Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * fix: stable Signed-off-by: Innei <i@innei.in> * move Signed-off-by: Innei <i@innei.in> * fix: endpoint Signed-off-by: Innei <i@innei.in> * chore: add tracker Signed-off-by: Innei <i@innei.in> * fix: remove fetch meta info Signed-off-by: Innei <i@innei.in> * fix: seo Signed-off-by: Innei <i@innei.in> * fix: align Signed-off-by: Innei <i@innei.in> * feat: add close button Signed-off-by: Innei <i@innei.in> * feat: set pictureViewMasonry default to true --------- Signed-off-by: Innei <i@innei.in> Co-authored-by: DIYgod <i@diygod.me>
This commit is contained in:
parent
7678bd4860
commit
3f9533aa3a
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linejoin="round" stroke-width="2" d="M20.5 5.75c0-.232 0-.348-.01-.446a2 2 0 0 0-1.794-1.794c-.098-.01-.214-.01-.446-.01h-2c-.232 0-.348 0-.446.01a2 2 0 0 0-1.794 1.794c-.01.098-.01.214-.01.446s0 .348.01.446a2 2 0 0 0 1.794 1.794c.098.01.214.01.446.01h2c.232 0 .348 0 .446-.01a2 2 0 0 0 1.794-1.794c.01-.098.01-.214.01-.446zM10 6.75c0-1.167 0-1.751-.236-2.193a2 2 0 0 0-.821-.82C8.5 3.5 7.917 3.5 6.75 3.5s-1.751 0-2.193.236a2 2 0 0 0-.82.821C3.5 5 3.5 5.583 3.5 6.75v2c0 1.167 0 1.751.236 2.193a2 2 0 0 0 .821.82C5 12 5.583 12 6.75 12s1.751 0 2.193-.236a2 2 0 0 0 .82-.821C10 10.5 10 9.917 10 8.75zM20.5 15.25c0-1.167 0-1.751-.236-2.193a2 2 0 0 0-.821-.82C19 12 18.417 12 17.25 12s-1.751 0-2.193.236a2 2 0 0 0-.82.821C14 13.5 14 14.083 14 15.25v2c0 1.167 0 1.751.236 2.193a2 2 0 0 0 .821.82c.442.237 1.026.237 2.193.237s1.751 0 2.193-.236a2 2 0 0 0 .82-.821c.237-.442.237-1.026.237-2.193zM10 18.25c0-.232 0-.348-.01-.446a2 2 0 0 0-1.794-1.794C8.098 16 7.982 16 7.75 16h-2c-.232 0-.348 0-.446.01a2 2 0 0 0-1.794 1.794c-.01.098-.01.214-.01.446s0 .348.01.446a2 2 0 0 0 1.794 1.794c.098.01.214.01.446.01h2c.232 0 .348 0 .446-.01a2 2 0 0 0 1.794-1.794c.01-.098.01-.214.01-.446z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linejoin="round" stroke-width="2" d="M20.5 6.75c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847c-.288-.069-.637-.069-1.334-.069-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846C14 5.704 14 6.053 14 6.75c0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334zM10 6.75c0-.697 0-1.046-.07-1.334A2.5 2.5 0 0 0 8.085 3.57C7.796 3.5 7.447 3.5 6.75 3.5c-.697 0-1.046 0-1.334.07A2.5 2.5 0 0 0 3.57 5.415C3.5 5.704 3.5 6.053 3.5 6.75c0 .697 0 1.046.07 1.334A2.5 2.5 0 0 0 5.415 9.93C5.704 10 6.053 10 6.75 10c.697 0 1.046 0 1.334-.07A2.5 2.5 0 0 0 9.93 8.085C10 7.796 10 7.447 10 6.75zM20.5 17.25c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847C18.296 14 17.947 14 17.25 14c-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846C14 16.204 14 16.553 14 17.25c0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334zM10 17.25c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847C7.796 14 7.447 14 6.75 14c-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846c-.069.288-.069.637-.069 1.334 0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -31,6 +31,7 @@
|
|||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@egjs/react-infinitegrid": "4.12.0",
|
||||
"@egoist/tipc": "0.3.2",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
|
|
@ -124,6 +125,7 @@
|
|||
"tailwind-merge": "2.4.0",
|
||||
"tldts": "6.1.35",
|
||||
"unified": "11.0.5",
|
||||
"use-context-selector": "2.0.0",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"vfile": "6.0.2",
|
||||
"zod": "3.23.8",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@egjs/react-infinitegrid':
|
||||
specifier: 4.12.0
|
||||
version: 4.12.0
|
||||
'@egoist/tipc':
|
||||
specifier: 0.3.2
|
||||
version: 0.3.2(electron@30.1.2)(react@18.3.1)
|
||||
|
|
@ -301,6 +304,9 @@ importers:
|
|||
unified:
|
||||
specifier: 11.0.5
|
||||
version: 11.0.5
|
||||
use-context-selector:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0(react@18.3.1)(scheduler@0.23.2)
|
||||
usehooks-ts:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0(react@18.3.1)
|
||||
|
|
@ -625,10 +631,37 @@ packages:
|
|||
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@cfcs/core@0.0.24':
|
||||
resolution: {integrity: sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==}
|
||||
|
||||
'@cfcs/core@0.0.5':
|
||||
resolution: {integrity: sha512-TZ/RXKV7MUrqFpiX+16uaaptv5jgChWNsuE6w8Rn3eJbO1PFd4Wpn25zeJbii7ch46ck8/ZIIYCcW3pHiYtm4Q==}
|
||||
|
||||
'@develar/schema-utils@2.6.5':
|
||||
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
||||
engines: {node: '>= 8.9.0'}
|
||||
|
||||
'@egjs/children-differ@1.0.1':
|
||||
resolution: {integrity: sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==}
|
||||
|
||||
'@egjs/component@3.0.5':
|
||||
resolution: {integrity: sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==}
|
||||
|
||||
'@egjs/grid@1.16.0':
|
||||
resolution: {integrity: sha512-w344hL2HNwmOHu379EG9I8ULkWF4A0cVf1XDn5nGlLXc+8c/rMvQadMBkGPOcdWMEKvFZpEiOPI7tWlXLOCloQ==}
|
||||
|
||||
'@egjs/imready@1.4.1':
|
||||
resolution: {integrity: sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==}
|
||||
|
||||
'@egjs/infinitegrid@4.12.0':
|
||||
resolution: {integrity: sha512-zoDz+mag7DAropcgmxW0Bx3JQ5ID19lk+AUN9rf9U7diwmO8IBfddX44jOVc9+ya5yZ+Goj95Fkm5S3GqCOrZQ==}
|
||||
|
||||
'@egjs/list-differ@1.0.1':
|
||||
resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==}
|
||||
|
||||
'@egjs/react-infinitegrid@4.12.0':
|
||||
resolution: {integrity: sha512-V661KH1nka13wgy73N0IGBazZGe7LiECml1JDqB5DP6axTERUVCs8KRHadHu6oQihDeXwJIHe1YRhhMN9eAnUw==}
|
||||
|
||||
'@egoist/tailwindcss-icons@1.8.1':
|
||||
resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==}
|
||||
peerDependencies:
|
||||
|
|
@ -6621,6 +6654,12 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-context-selector@2.0.0:
|
||||
resolution: {integrity: sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0'
|
||||
scheduler: '>=0.19.0'
|
||||
|
||||
use-isomorphic-layout-effect@1.1.2:
|
||||
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
|
||||
peerDependencies:
|
||||
|
|
@ -7155,11 +7194,50 @@ snapshots:
|
|||
'@babel/helper-validator-identifier': 7.24.7
|
||||
to-fast-properties: 2.0.0
|
||||
|
||||
'@cfcs/core@0.0.24':
|
||||
dependencies:
|
||||
'@egjs/component': 3.0.5
|
||||
|
||||
'@cfcs/core@0.0.5':
|
||||
dependencies:
|
||||
'@egjs/component': 3.0.5
|
||||
|
||||
'@develar/schema-utils@2.6.5':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv-keywords: 3.5.2(ajv@6.12.6)
|
||||
|
||||
'@egjs/children-differ@1.0.1':
|
||||
dependencies:
|
||||
'@egjs/list-differ': 1.0.1
|
||||
|
||||
'@egjs/component@3.0.5': {}
|
||||
|
||||
'@egjs/grid@1.16.0':
|
||||
dependencies:
|
||||
'@egjs/children-differ': 1.0.1
|
||||
'@egjs/component': 3.0.5
|
||||
'@egjs/imready': 1.4.1
|
||||
|
||||
'@egjs/imready@1.4.1':
|
||||
dependencies:
|
||||
'@cfcs/core': 0.0.24
|
||||
'@egjs/component': 3.0.5
|
||||
|
||||
'@egjs/infinitegrid@4.12.0':
|
||||
dependencies:
|
||||
'@cfcs/core': 0.0.5
|
||||
'@egjs/children-differ': 1.0.1
|
||||
'@egjs/component': 3.0.5
|
||||
'@egjs/grid': 1.16.0
|
||||
'@egjs/list-differ': 1.0.1
|
||||
|
||||
'@egjs/list-differ@1.0.1': {}
|
||||
|
||||
'@egjs/react-infinitegrid@4.12.0':
|
||||
dependencies:
|
||||
'@egjs/infinitegrid': 4.12.0
|
||||
|
||||
'@egoist/tailwindcss-icons@1.8.1(tailwindcss@3.4.7)':
|
||||
dependencies:
|
||||
'@iconify/utils': 2.1.25
|
||||
|
|
@ -14438,6 +14516,11 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.3
|
||||
|
||||
use-context-selector@2.0.0(react@18.3.1)(scheduler@0.23.2):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
use-isomorphic-layout-effect@1.1.2(@types/react@18.3.3)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
|
|
|||
|
|
@ -3231,6 +3231,8 @@ declare const _routes: hono_hono_base.HonoBase<Env, {
|
|||
type: "photo" | "video";
|
||||
url: string;
|
||||
preview_image_url?: string | undefined;
|
||||
height?: number | undefined;
|
||||
width?: number | undefined;
|
||||
}[] | null | undefined;
|
||||
attachments?: {
|
||||
url: string;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const createDefaultSettings = (): UISettings => ({
|
|||
// Content
|
||||
readerRenderInlineStyle: false,
|
||||
codeHighlightTheme: "github-dark",
|
||||
|
||||
// View
|
||||
pictureViewMasonry: true,
|
||||
})
|
||||
|
||||
export const {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { memo, useState } from "react"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
|
||||
type ImpressionProps = {
|
||||
event: string
|
||||
onTrack?: () => any
|
||||
properties?: Record<string, any>
|
||||
}
|
||||
export const ImpressionView: Component<
|
||||
{ shouldTrack?: boolean } & ImpressionProps
|
||||
> = (props) => {
|
||||
const { shouldTrack = true, ...rest } = props
|
||||
if (!shouldTrack) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
return <ImpressionViewImpl {...rest} />
|
||||
}
|
||||
|
||||
const ImpressionViewImpl: Component<ImpressionProps> = memo((props) => {
|
||||
const [impression, setImpression] = useState(false)
|
||||
|
||||
const { ref } = useInView({
|
||||
initialInView: false,
|
||||
triggerOnce: true,
|
||||
onChange(inView) {
|
||||
if (!inView) {
|
||||
return
|
||||
}
|
||||
setImpression(true)
|
||||
|
||||
window.posthog?.capture(props.event, {
|
||||
impression: 1,
|
||||
...props.properties,
|
||||
})
|
||||
props.onTrack?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
{!impression && <span ref={ref} />}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
ImpressionViewImpl.displayName = "ImpressionView"
|
||||
|
|
@ -29,7 +29,7 @@ export const PageErrorFallback: FC<AppErrorFallbackProps> = (props) => {
|
|||
|
||||
<div className="center gap-4">
|
||||
<StyledButton onClick={() => props.resetError()} variant="primary">
|
||||
Reset
|
||||
Retry
|
||||
</StyledButton>
|
||||
|
||||
<StyledButton
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function FeedIcon({
|
|||
} as any
|
||||
}
|
||||
className={cn(
|
||||
"mr-2 flex shrink-0 items-center justify-center rounded-sm text-xs font-medium",
|
||||
"mr-2 flex shrink-0 items-baseline justify-center rounded-sm text-xs font-medium",
|
||||
"bg-[var(--fo-light-background)] text-white dark:bg-[var(--fo-dark-background)] dark:text-black",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { tipcClient } from "@renderer/lib/client"
|
|||
import { getProxyUrl } from "@renderer/lib/img-proxy"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { saveImageDimensionsToDb } from "@renderer/store/image/db"
|
||||
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -25,6 +26,7 @@ export type MediaProps = BaseProps &
|
|||
popper?: boolean
|
||||
type: "photo"
|
||||
previewImageUrl?: string
|
||||
cacheDimensions?: boolean
|
||||
})
|
||||
| (VideoHTMLAttributes<HTMLVideoElement> & {
|
||||
proxy?: {
|
||||
|
|
@ -82,6 +84,18 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
}
|
||||
props.onClick?.(e as any)
|
||||
})
|
||||
const handleOnLoad: React.ReactEventHandler<HTMLImageElement> =
|
||||
useEventCallback((e) => {
|
||||
rest.onLoad?.(e as any)
|
||||
if ("cacheDimensions" in props && props.cacheDimensions && src) {
|
||||
saveImageDimensionsToDb(src, {
|
||||
src,
|
||||
width: e.currentTarget.naturalWidth,
|
||||
height: e.currentTarget.naturalHeight,
|
||||
ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const InnerContent = useMemo(() => {
|
||||
switch (type) {
|
||||
|
|
@ -98,6 +112,7 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
mediaContainerClassName,
|
||||
)}
|
||||
src={imgSrc}
|
||||
onLoad={handleOnLoad}
|
||||
onClick={handleClick}
|
||||
{...(!disableContextMenu ?
|
||||
{
|
||||
|
|
@ -171,6 +186,7 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
disableContextMenu,
|
||||
errorHandle,
|
||||
handleClick,
|
||||
handleOnLoad,
|
||||
hidden,
|
||||
imgSrc,
|
||||
mediaContainerClassName,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ const Wrapper: Component<{
|
|||
className="absolute bottom-4 right-4 flex gap-3"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="center fixed right-6 top-6 size-8 rounded-full border bg-theme-background text-foreground/60"
|
||||
type="button"
|
||||
>
|
||||
<i className="i-mgc-close-cute-re" />
|
||||
</button>
|
||||
{!!window.electron && (
|
||||
<ActionButton
|
||||
tooltip="Download"
|
||||
|
|
|
|||
|
|
@ -13,23 +13,34 @@ import { Swiper, SwiperSlide } from "swiper/react"
|
|||
|
||||
import styles from "./index.module.css"
|
||||
|
||||
const defaultProxySize = {
|
||||
width: 600,
|
||||
height: 0,
|
||||
}
|
||||
export function SwipeMedia({
|
||||
media,
|
||||
uniqueKey,
|
||||
className,
|
||||
imgClassName,
|
||||
onPreview,
|
||||
proxySize = defaultProxySize,
|
||||
forceSwiper,
|
||||
}: {
|
||||
media?: MediaModel[] | null
|
||||
uniqueKey?: string
|
||||
className?: string
|
||||
imgClassName?: string
|
||||
onPreview?: (media: MediaModel[], index?: number) => void
|
||||
proxySize?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
forceSwiper?: boolean
|
||||
}) {
|
||||
const uniqMedia = uniqBy(media, "url")
|
||||
|
||||
const hoverRef = useRef<HTMLDivElement>(null)
|
||||
const [enableSwipe, setEnableSwipe] = useState(false)
|
||||
const [enableSwipe, setEnableSwipe] = useState(!!forceSwiper)
|
||||
useHover(
|
||||
(event) => {
|
||||
if (event.active) {
|
||||
|
|
@ -38,6 +49,7 @@ export function SwipeMedia({
|
|||
},
|
||||
{
|
||||
target: hoverRef,
|
||||
enabled: !forceSwiper,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -73,14 +85,12 @@ export function SwipeMedia({
|
|||
<Media
|
||||
className={cn(imgClassName, "size-full rounded-none")}
|
||||
alt="cover"
|
||||
cacheDimensions={med.type === "photo"}
|
||||
src={med.url}
|
||||
type={med.type}
|
||||
previewImageUrl={med.preview_image_url}
|
||||
loading="lazy"
|
||||
proxy={{
|
||||
width: 600,
|
||||
height: 600,
|
||||
}}
|
||||
proxy={proxySize}
|
||||
disableContextMenu
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -112,16 +122,14 @@ export function SwipeMedia({
|
|||
e.stopPropagation()
|
||||
onPreview?.(uniqMedia)
|
||||
}}
|
||||
cacheDimensions={uniqMedia[0].type === "photo"}
|
||||
className="size-full rounded-none object-cover sm:transition-transform sm:duration-300 sm:ease-in-out sm:group-hover:scale-105"
|
||||
alt="cover"
|
||||
src={uniqMedia[0].url}
|
||||
type={uniqMedia[0].type}
|
||||
previewImageUrl={uniqMedia[0].preview_image_url}
|
||||
loading="lazy"
|
||||
proxy={{
|
||||
width: 600,
|
||||
height: 600,
|
||||
}}
|
||||
proxy={proxySize}
|
||||
disableContextMenu
|
||||
/>
|
||||
) :
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { stopPropagation } from "@renderer/lib/dom"
|
|||
import { cn } from "@renderer/lib/utils"
|
||||
import * as React from "react"
|
||||
|
||||
import { ScrollElementContext } from "./ctx"
|
||||
import styles from "./index.module.css"
|
||||
|
||||
const Corner = React.forwardRef<
|
||||
|
|
@ -146,20 +147,29 @@ export const ScrollArea = React.forwardRef<
|
|||
mask = false,
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<Root className={rootClassName}>
|
||||
<Viewport
|
||||
ref={ref}
|
||||
onWheel={stopPropagation}
|
||||
className={cn(
|
||||
flex ? "[&>div]:!flex [&>div]:!flex-col" : "",
|
||||
viewportClassName,
|
||||
)}
|
||||
mask={mask}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar className={scrollbarClassName} />
|
||||
</Root>
|
||||
),
|
||||
) => {
|
||||
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(
|
||||
null,
|
||||
)
|
||||
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
|
||||
|
||||
return (
|
||||
<ScrollElementContext.Provider value={viewportRef}>
|
||||
<Root className={rootClassName}>
|
||||
<Viewport
|
||||
ref={setViewportRef}
|
||||
onWheel={stopPropagation}
|
||||
className={cn(
|
||||
flex ? "[&>div]:!flex [&>div]:!flex-col" : "",
|
||||
viewportClassName,
|
||||
)}
|
||||
mask={mask}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar className={scrollbarClassName} />
|
||||
</Root>
|
||||
</ScrollElementContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
export const ScrollElementContext = createContext<HTMLElement | null>(null)
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import clsx from "clsx"
|
||||
import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { useCallback, useContext, useRef, useState } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { ScrollElementContext } from "./ctx"
|
||||
|
||||
const THRESHOLD = 0
|
||||
export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
|
||||
ref,
|
||||
|
|
@ -64,7 +66,9 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
|
|||
}, [eventHandler, getDomRef, element])
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!ref?.current) { return }
|
||||
if (!ref?.current) {
|
||||
return
|
||||
}
|
||||
const $ = ref.current
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
eventHandler()
|
||||
|
|
@ -92,3 +96,9 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
|
|||
"",
|
||||
] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scroll area element when in radix scroll area
|
||||
* @returns
|
||||
*/
|
||||
export const useScrollViewElement = () => useContext(ScrollElementContext)
|
||||
|
|
@ -3,12 +3,13 @@ import { useLayoutEffect, useRef } from "react"
|
|||
export const useRefValue = <S>(
|
||||
value: S,
|
||||
): Readonly<{
|
||||
current: Readonly<S>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
current: S extends Function ? S : Readonly<S>
|
||||
}> => {
|
||||
const ref = useRef<S>(value)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref
|
||||
return ref as any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { useAuthQuery } from "@renderer/hooks/common"
|
||||
import { apiClient } from "@renderer/lib/api-fetch"
|
||||
import { defineQuery } from "@renderer/lib/defineQuery"
|
||||
import { MarkAllButton } from "@renderer/modules/entry-column/mark-all-button"
|
||||
import { MarkAllButton } from "@renderer/modules/entry-column/components/mark-all-button"
|
||||
import type { FC } from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Skeleton } from "@renderer/components/ui/skeleton"
|
||||
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
||||
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
|
||||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
export function ArticleItem({
|
||||
entryId,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Skeleton } from "@renderer/components/ui/skeleton"
|
||||
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
||||
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
|
||||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
export function AudioItem({
|
||||
entryId,
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { noop } from "foxact/noop"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { createContext, useCallback, useContext } from "react"
|
||||
import {
|
||||
createContext as createContextSelector,
|
||||
useContextSelector,
|
||||
} from "use-context-selector"
|
||||
|
||||
export const MasonryItemWidthContext = createContext(0)
|
||||
|
||||
export const useMasonryItemWidth = () => useContext(MasonryItemWidthContext)
|
||||
|
||||
export const MasonryItemsAspectRatioContext = createContextSelector(
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
|
||||
export const useMasonryItemRatio = (url: string) =>
|
||||
useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url])
|
||||
|
||||
export const MasonryItemsAspectRatioSetterContext =
|
||||
createContext<Dispatch<SetStateAction<Record<string, number>>>>(noop)
|
||||
|
||||
export const useSetStableMasonryItemRatio = () => {
|
||||
const ctx = useContext(MasonryItemsAspectRatioSetterContext)
|
||||
return useCallback(
|
||||
(url: string, ratio: number) => {
|
||||
ctx((prev) => {
|
||||
// Skip if the ratio is already set, make it stable
|
||||
if (prev[url]) return prev
|
||||
|
||||
return { ...prev, [url]: ratio }
|
||||
})
|
||||
},
|
||||
[ctx],
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Skeleton } from "@renderer/components/ui/skeleton"
|
||||
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
||||
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
|
||||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
export function NotificationItem({
|
||||
entryId,
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { SwipeMedia } from "@renderer/components/ui/media/swipe-media"
|
||||
import { ReactVirtuosoItemPlaceholder } from "@renderer/components/ui/placeholder"
|
||||
import { Skeleton } from "@renderer/components/ui/skeleton"
|
||||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import { useImageDimensions } from "@renderer/store/image"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
|
||||
import { usePreviewMedia } from "../../../components/ui/media/hooks"
|
||||
import { EntryItemWrapper } from "../layouts/EntryItemWrapper"
|
||||
import { GridItem } from "../templates/grid-item-template"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
import {
|
||||
useMasonryItemRatio,
|
||||
useMasonryItemWidth,
|
||||
useSetStableMasonryItemRatio,
|
||||
} from "./contexts/picture-masonry-context"
|
||||
|
||||
export function PictureItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
const entry = useEntry(entryId) || entryPreview
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
({ entryId }) => entryId === entry?.entries.id,
|
||||
)
|
||||
|
||||
const previewMedia = usePreviewMedia()
|
||||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
return (
|
||||
<GridItem
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
>
|
||||
<div className="relative flex gap-2 overflow-x-auto">
|
||||
{entry.entries.media ? (
|
||||
<SwipeMedia
|
||||
media={entry.entries.media}
|
||||
className={cn(
|
||||
"aspect-square",
|
||||
"w-full shrink-0 rounded-md",
|
||||
isActive && "rounded-b-none",
|
||||
)}
|
||||
imgClassName="object-cover"
|
||||
uniqueKey={entryId}
|
||||
onPreview={(media, i) => {
|
||||
previewMedia(media, i)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="center aspect-square w-full flex-col gap-1 bg-muted text-xs text-muted-foreground">
|
||||
<i className="i-mgc-sad-cute-re size-6" />
|
||||
No media available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridItem>
|
||||
)
|
||||
}
|
||||
|
||||
const proxySize = {
|
||||
width: 600,
|
||||
height: 0,
|
||||
}
|
||||
export const PictureWaterFallItem = memo(function PictureWaterFallItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
const entry = useEntry(entryId) || entryPreview
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
({ entryId }) => entryId === entry?.entries.id,
|
||||
)
|
||||
|
||||
const previewMedia = usePreviewMedia()
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<EntryItemWrapper
|
||||
view={FeedViewType.Pictures}
|
||||
entry={entry}
|
||||
itemClassName="group hover:bg-theme-item-hover"
|
||||
>
|
||||
<GridItem
|
||||
wrapperClassName="p-0 h-full flex flex-col"
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
>
|
||||
{entry.entries.media ? (
|
||||
<MasonryItemFixedDimensionWrapper url={entry.entries.media[0].url}>
|
||||
<SwipeMedia
|
||||
|
||||
media={entry.entries.media}
|
||||
className={cn(
|
||||
"w-full shrink-0 grow rounded-md",
|
||||
isActive && "rounded-b-none",
|
||||
)}
|
||||
proxySize={proxySize}
|
||||
imgClassName="object-cover"
|
||||
uniqueKey={entryId}
|
||||
onPreview={(media, i) => {
|
||||
previewMedia(media, i)
|
||||
}}
|
||||
/>
|
||||
</MasonryItemFixedDimensionWrapper>
|
||||
) : null}
|
||||
</GridItem>
|
||||
</EntryItemWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
const MasonryItemFixedDimensionWrapper = (
|
||||
props: PropsWithChildren<{
|
||||
url: string
|
||||
}>,
|
||||
) => {
|
||||
const { url, children } = props
|
||||
const dim = useImageDimensions(url)
|
||||
const itemWidth = useMasonryItemWidth()
|
||||
|
||||
const itemHeight = dim ? itemWidth / dim.ratio : itemWidth
|
||||
const stableRadio = useState(() => itemWidth / itemHeight || 1)[0]
|
||||
const setItemStableRatio = useSetStableMasonryItemRatio()
|
||||
|
||||
const stableRadioCtx = useMasonryItemRatio(url)
|
||||
|
||||
useEffect(() => {
|
||||
setItemStableRatio(url, stableRadio)
|
||||
}, [setItemStableRatio, stableRadio, url])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full gap-2 overflow-x-auto"
|
||||
style={{
|
||||
width: itemWidth,
|
||||
height: itemWidth / stableRadioCtx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PictureItemSkeleton = (
|
||||
<div className="relative max-w-md rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative">
|
||||
<div className="p-1.5">
|
||||
<div className="relative flex gap-2 overflow-x-auto">
|
||||
<div className="relative flex aspect-square w-full shrink-0 items-center overflow-hidden rounded-md">
|
||||
<Skeleton className="size-full overflow-hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
|
||||
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
|
||||
<Skeleton className="h-4 w-3/4 " />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
|
||||
<Skeleton className="mr-0.5 size-4" />
|
||||
<Skeleton className="h-3 w-1/2 " />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<Skeleton className="h-3 w-12 " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { MasonryInfiniteGrid } from "@egjs/react-infinitegrid"
|
||||
import { useScrollViewElement } from "@renderer/components/ui/scroll-area/hooks"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { getEntry } from "@renderer/store/entry"
|
||||
import { imageActions } from "@renderer/store/image"
|
||||
import { throttle } from "lodash-es"
|
||||
import type { CSSProperties, FC, PropsWithChildren } from "react"
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { EntryItemSkeleton } from "../item"
|
||||
import {
|
||||
MasonryItemsAspectRatioContext,
|
||||
MasonryItemsAspectRatioSetterContext,
|
||||
MasonryItemWidthContext,
|
||||
} from "./contexts/picture-masonry-context"
|
||||
import { PictureWaterFallItem } from "./picture-item"
|
||||
|
||||
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
|
||||
|
||||
const breakpoints = {
|
||||
0: 1,
|
||||
// 32rem => 32 * 16= 512
|
||||
512: 2,
|
||||
// 48rem => 48 * 16= 768
|
||||
768: 3,
|
||||
// 72rem => 72 * 16= 1152
|
||||
1152: 4,
|
||||
// 80rem => 80 * 16= 1280
|
||||
1280: 5,
|
||||
1536: 6,
|
||||
1792: 7,
|
||||
2048: 8,
|
||||
}
|
||||
const getCurrentColumn = (w: number) => {
|
||||
// Initialize column count with the minimum number of columns
|
||||
let columns = 1
|
||||
|
||||
// Iterate through each breakpoint and determine the column count
|
||||
for (const [breakpoint, cols] of Object.entries(breakpoints)) {
|
||||
if (w >= Number.parseInt(breakpoint)) {
|
||||
columns = cols
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
export const PictureMasonry: FC<MasonryProps> = (props) => {
|
||||
const { data } = props
|
||||
const [isInit, setIsInit] = useState(false)
|
||||
|
||||
const restoreDimensions = useEventCallback(async () => {
|
||||
const images = [] as string[]
|
||||
data.forEach((entryId) => {
|
||||
const entry = getEntry(entryId)
|
||||
|
||||
images.push(...imageActions.getImagesFromEntry(entry.entries))
|
||||
})
|
||||
return imageActions.fetchDimensionsFromDb(images)
|
||||
})
|
||||
useEffect(() => {
|
||||
restoreDimensions().finally(() => {
|
||||
setIsInit(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!isInit) return null
|
||||
|
||||
return (
|
||||
<PictureMasonryImpl {...props} restoreDimensions={restoreDimensions} />
|
||||
)
|
||||
}
|
||||
interface MasonryProps {
|
||||
data: string[]
|
||||
endReached: () => Promise<any>
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
const PictureMasonryImpl = ({
|
||||
data,
|
||||
endReached,
|
||||
hasNextPage,
|
||||
restoreDimensions,
|
||||
}: MasonryProps & { restoreDimensions: () => Promise<void> }) => {
|
||||
const masonryRef = useRef<MasonryInfiniteGrid>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [currentItemWidth, setCurrentItemWidth] = useState(0)
|
||||
const [currentColumn, setCurrentColumn] = useState(1)
|
||||
const $scroll = useScrollViewElement()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const $warpper = containerRef.current
|
||||
if (!$warpper) return
|
||||
const recal = throttle(() => {
|
||||
const column = getCurrentColumn($warpper.clientWidth)
|
||||
setCurrentItemWidth(Math.trunc($warpper.clientWidth / column - 20))
|
||||
|
||||
setCurrentColumn(column)
|
||||
|
||||
masonryRef.current?.renderItems()
|
||||
}, 50)
|
||||
|
||||
let previousWidth = $warpper.offsetWidth
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = entry.contentRect.width
|
||||
|
||||
if (newWidth !== previousWidth) {
|
||||
previousWidth = newWidth
|
||||
recal()
|
||||
}
|
||||
}
|
||||
})
|
||||
recal()
|
||||
resizeObserver.observe($warpper)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const itemStyle: CSSProperties = useMemo(
|
||||
() => ({ width: currentItemWidth }),
|
||||
[currentItemWidth],
|
||||
)
|
||||
|
||||
const [masonryItemsRadio, setMasonryItemsRadio] = useState<
|
||||
Record<string, number>
|
||||
>({})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="p-4">
|
||||
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
|
||||
<MasonryItemWidthContext.Provider value={currentItemWidth}>
|
||||
<MasonryItemsAspectRatioSetterContext.Provider
|
||||
value={setMasonryItemsRadio}
|
||||
>
|
||||
<MasonryInfiniteGrid
|
||||
ref={masonryRef}
|
||||
placeholder={<LoadingSkeletonItem itemStyle={itemStyle} />}
|
||||
onRequestAppend={(e) => {
|
||||
if (!hasNextPage) return
|
||||
e.wait()
|
||||
const nextGroupKey = (+e.groupKey! || 0) + 1
|
||||
e.currentTarget.appendPlaceholders(10, nextGroupKey)
|
||||
endReached()
|
||||
.then(restoreDimensions).finally(() => {
|
||||
e.ready()
|
||||
})
|
||||
}}
|
||||
scrollContainer={$scroll}
|
||||
observeChildren
|
||||
useFirstRender
|
||||
gap={{ vertical: 24 }}
|
||||
column={currentColumn}
|
||||
>
|
||||
{data.map((entryId, index) => (
|
||||
<ItemWrapper
|
||||
data-grid-groupkey={index}
|
||||
itemStyle={itemStyle}
|
||||
key={entryId}
|
||||
entryId={entryId}
|
||||
>
|
||||
<PictureWaterFallItem entryId={entryId} />
|
||||
</ItemWrapper>
|
||||
))}
|
||||
</MasonryInfiniteGrid>
|
||||
</MasonryItemsAspectRatioSetterContext.Provider>
|
||||
</MasonryItemWidthContext.Provider>
|
||||
</MasonryItemsAspectRatioContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadingSkeletonItem = ({ itemStyle }: { itemStyle: CSSProperties }) => (
|
||||
<div style={itemStyle}>
|
||||
<EntryItemSkeleton single view={FeedViewType.Pictures} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ItemWrapperImpl = ({
|
||||
itemStyle,
|
||||
style,
|
||||
children,
|
||||
entryId,
|
||||
...rest
|
||||
}: {
|
||||
itemStyle: CSSProperties
|
||||
entryId: string
|
||||
} & React.HtmlHTMLAttributes<HTMLDivElement> &
|
||||
PropsWithChildren) => (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
...itemStyle,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ItemWrapper = memo(ItemWrapperImpl)
|
||||
|
|
@ -13,11 +13,11 @@ import { cn } from "@renderer/lib/utils"
|
|||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import { useFeedById } from "@renderer/store/feed"
|
||||
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
|
||||
import { MarkAllButton } from "./mark-all-button"
|
||||
import { StarIcon } from "./star-icon"
|
||||
import { EntryTranslation } from "./translation"
|
||||
import type { EntryListItemFC } from "./types"
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
|
||||
import { MarkAllButton } from "../components/mark-all-button"
|
||||
import { StarIcon } from "../star-icon"
|
||||
import { EntryTranslation } from "../translation"
|
||||
import type { EntryListItemFC } from "../types"
|
||||
|
||||
export const SocialMediaItem: EntryListItemFC = ({
|
||||
entryId,
|
||||
|
|
@ -8,13 +8,13 @@ import { Skeleton } from "@renderer/components/ui/skeleton"
|
|||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { urlToIframe } from "@renderer/lib/url-to-iframe"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { GridItem } from "@renderer/modules/entry-column/grid-item-template"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import { useHover } from "@use-gesture/react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
|
||||
import { GridItem } from "../templates/grid-item-template"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
const ViewTag = window.electron ? "webview" : "iframe"
|
||||
|
||||
|
|
@ -3,8 +3,8 @@ import { FeedViewType } from "@renderer/lib/enum"
|
|||
import { cn } from "@renderer/lib/utils"
|
||||
import { memo } from "react"
|
||||
|
||||
import { SocialMediaDateItem } from "../Items/social-media-item"
|
||||
import { MarkAllButton } from "./mark-all-button"
|
||||
import { SocialMediaDateItem } from "./social-media-item"
|
||||
|
||||
export const DateItem = memo(
|
||||
({
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useUISettingKey } from "@renderer/atoms/settings/ui"
|
||||
import { m } from "@renderer/components/common/Motion"
|
||||
import { FeedFoundCanBeFollowError } from "@renderer/components/errors/FeedFoundCanBeFollowErrorFallback"
|
||||
import { FeedNotFound } from "@renderer/components/errors/FeedNotFound"
|
||||
|
|
@ -14,24 +15,25 @@ import {
|
|||
useRouteParamsSelector,
|
||||
useRouteParms,
|
||||
} from "@renderer/hooks/biz/useRouteParams"
|
||||
import { useTitle } from "@renderer/hooks/common"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn, isBizId } from "@renderer/lib/utils"
|
||||
import { useFeed } from "@renderer/queries/feed"
|
||||
import { entryActions, useEntry } from "@renderer/store/entry"
|
||||
import { useFeedByIdSelector } from "@renderer/store/feed"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type {
|
||||
ScrollSeekConfiguration,
|
||||
VirtuosoGridProps,
|
||||
VirtuosoHandle,
|
||||
VirtuosoProps,
|
||||
} from "react-virtuoso"
|
||||
import { VirtuosoGrid } from "react-virtuoso"
|
||||
|
||||
import { EntryListHeader } from "./EntryListHeader"
|
||||
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
|
||||
import {
|
||||
EntryItem,
|
||||
EntryItemSkeleton,
|
||||
EntryItemSkeletonWithDelayShow,
|
||||
} from "./item"
|
||||
import { EntryItem, EntryItemSkeleton } from "./item"
|
||||
import { PictureMasonry } from "./Items/picture-masonry"
|
||||
import { EntryListHeader } from "./layouts/EntryListHeader"
|
||||
import { EntryEmptyList, EntryList, EntryListContent } from "./lists"
|
||||
import { girdClassNames } from "./styles"
|
||||
|
||||
|
|
@ -58,6 +60,8 @@ export function EntryColumn() {
|
|||
isCollection,
|
||||
} = useRouteParms()
|
||||
const activeEntry = useEntry(activeEntryId)
|
||||
const feedTitle = useFeedByIdSelector(routeFeedId, (feed) => feed?.title)
|
||||
useTitle(feedTitle)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntryId) return
|
||||
|
|
@ -80,7 +84,7 @@ export function EntryColumn() {
|
|||
List: EntryListContent,
|
||||
Footer: useCallback(() => {
|
||||
if (!isFetchingNextPage) return null
|
||||
return <EntryItemSkeletonWithDelayShow view={view} />
|
||||
return <EntryItemSkeleton view={view} />
|
||||
}, [isFetchingNextPage, view]),
|
||||
ScrollSeekPlaceholder: useCallback(
|
||||
() => <EntryItemSkeleton view={view} single />,
|
||||
|
|
@ -96,11 +100,11 @@ export function EntryColumn() {
|
|||
customScrollParent: scrollRef.current!,
|
||||
|
||||
totalCount: entries.totalCount,
|
||||
endReached: () => {
|
||||
endReached: useCallback(async () => {
|
||||
if (!entries.isFetchingNextPage && entries.hasNextPage) {
|
||||
entries.fetchNextPage()
|
||||
await entries.fetchNextPage()
|
||||
}
|
||||
},
|
||||
}, [entries]),
|
||||
data: entriesIds,
|
||||
onScroll: () => {
|
||||
if (!isInteracted.current) {
|
||||
|
|
@ -172,10 +176,9 @@ export function EntryColumn() {
|
|||
)
|
||||
) : view && views[view].gridMode ?
|
||||
(
|
||||
<VirtuosoGrid
|
||||
listClassName={girdClassNames}
|
||||
{...virtuosoOptions}
|
||||
ref={virtuosoRef}
|
||||
<ListGird
|
||||
virtuosoOptions={virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
/>
|
||||
) :
|
||||
(
|
||||
|
|
@ -192,11 +195,48 @@ export function EntryColumn() {
|
|||
)
|
||||
}
|
||||
|
||||
const ListGird = ({
|
||||
virtuosoOptions,
|
||||
virtuosoRef,
|
||||
}: {
|
||||
virtuosoOptions: Omit<
|
||||
VirtuosoGridProps<string, unknown>,
|
||||
"data" | "endReached"
|
||||
> & {
|
||||
data: string[]
|
||||
endReached: () => Promise<any>
|
||||
}
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
}) => {
|
||||
const masonry = useUISettingKey("pictureViewMasonry")
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
const feedId = useRouteParamsSelector((s) => s.feedId)
|
||||
if (masonry && view === FeedViewType.Pictures) {
|
||||
return (
|
||||
<PictureMasonry
|
||||
key={feedId}
|
||||
hasNextPage={virtuosoOptions.totalCount! > virtuosoOptions.data.length}
|
||||
endReached={virtuosoOptions.endReached}
|
||||
data={virtuosoOptions.data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<VirtuosoGrid
|
||||
listClassName={girdClassNames}
|
||||
{...virtuosoOptions}
|
||||
ref={virtuosoRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AddFeedHelper = () => {
|
||||
const feedId = useRouteParamsSelector((s) => s.feedId)
|
||||
const feedQuery = useFeed({ id: feedId })
|
||||
|
||||
if (!feedId) { return }
|
||||
if (!feedId) {
|
||||
return
|
||||
}
|
||||
if (feedId === FEED_COLLECTION_LIST || feedId === ROUTE_FEED_PENDING) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,27 @@
|
|||
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
|
||||
import { ListItemHoverOverlay } from "@renderer/components/ui/list-item-hover-overlay"
|
||||
import { LoadingCircle } from "@renderer/components/ui/loading"
|
||||
import { views } from "@renderer/constants"
|
||||
import { useAsRead } from "@renderer/hooks/biz/useAsRead"
|
||||
import { useEntryActions } from "@renderer/hooks/biz/useEntryActions"
|
||||
import { useFeedActions } from "@renderer/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery } from "@renderer/hooks/common"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import type { FlatEntryModel } from "@renderer/store/entry"
|
||||
import { entryActions } from "@renderer/store/entry"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import type { FC, ReactNode } from "react"
|
||||
import { memo, useCallback } from "react"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
import { memo } from "react"
|
||||
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
|
||||
import { ArticleItem, ArticleItemSkeleton } from "./article-item"
|
||||
import { AudioItem, AudioItemSkeleton } from "./audio-item"
|
||||
import { ArticleItem, ArticleItemSkeleton } from "./Items/article-item"
|
||||
import { AudioItem, AudioItemSkeleton } from "./Items/audio-item"
|
||||
import {
|
||||
NotificationItem,
|
||||
NotificationItemSkeleton,
|
||||
} from "./notification-item"
|
||||
import { PictureItem, PictureItemSkeleton } from "./picture-item"
|
||||
import { SocialMediaItem, SocialMediaItemSkeleton } from "./social-media-item"
|
||||
} from "./Items/notification-item"
|
||||
import { PictureItem, PictureItemSkeleton } from "./Items/picture-item"
|
||||
import { SocialMediaItem, SocialMediaItemSkeleton } from "./Items/social-media-item"
|
||||
import { VideoItem, VideoItemSkeleton } from "./Items/video-item"
|
||||
import { EntryItemWrapper } from "./layouts/EntryItemWrapper"
|
||||
import { girdClassNames } from "./styles"
|
||||
import type { EntryListItemFC } from "./types"
|
||||
import { VideoItem, VideoItemSkeleton } from "./video-item"
|
||||
|
||||
interface EntryItemProps {
|
||||
entryId: string
|
||||
|
|
@ -43,17 +34,6 @@ function EntryItemImpl({
|
|||
entry: FlatEntryModel
|
||||
view?: number
|
||||
}) {
|
||||
const { items } = useEntryActions({
|
||||
view,
|
||||
entry,
|
||||
})
|
||||
|
||||
const { items: feedItems } = useFeedActions({
|
||||
feedId: entry.feedId,
|
||||
view,
|
||||
type: "entryList",
|
||||
})
|
||||
|
||||
const translation = useAuthQuery(
|
||||
Queries.ai.translation({
|
||||
entry,
|
||||
|
|
@ -70,26 +50,6 @@ function EntryItemImpl({
|
|||
},
|
||||
)
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
({ entryId }) => entryId === entry.entries.id,
|
||||
)
|
||||
|
||||
const asRead = useAsRead(entry)
|
||||
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
|
||||
|
||||
const handleMouseEnter = useDebounceCallback(
|
||||
() => {
|
||||
if (!hoverMarkUnread) return
|
||||
if (!document.hasFocus()) return
|
||||
if (asRead) return
|
||||
|
||||
entryActions.markRead(entry.feedId, entry.entries.id, true)
|
||||
},
|
||||
233,
|
||||
{
|
||||
leading: false,
|
||||
},
|
||||
)
|
||||
let Item: EntryListItemFC
|
||||
|
||||
switch (view) {
|
||||
|
|
@ -122,82 +82,15 @@ function EntryItemImpl({
|
|||
}
|
||||
}
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
navigate({
|
||||
entryId: entry.entries.id,
|
||||
})
|
||||
},
|
||||
[entry.entries.id, navigate],
|
||||
)
|
||||
const handleDoubleClick: React.MouseEventHandler<HTMLDivElement> =
|
||||
useCallback(
|
||||
() => entry.entries.url && window.open(entry.entries.url, "_blank"),
|
||||
[entry.entries.url],
|
||||
)
|
||||
const handleContextMenu: React.MouseEventHandler<HTMLDivElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
showNativeMenu(
|
||||
[
|
||||
...items
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: item.onClick,
|
||||
shortcut: item.shortcut,
|
||||
})),
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
...feedItems.filter((item) => !item.disabled),
|
||||
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: "Copy Entry ID",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(entry.entries.id)
|
||||
},
|
||||
},
|
||||
],
|
||||
e,
|
||||
)
|
||||
},
|
||||
[items, feedItems, entry.entries.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-entry-id={entry.entries.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md bg-theme-background transition-colors",
|
||||
asRead ?
|
||||
"text-zinc-700 dark:text-neutral-400" :
|
||||
"text-zinc-900 dark:text-neutral-300",
|
||||
Item.wrapperClassName,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<ListItemHoverOverlay
|
||||
className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Item entryId={entry.entries.id} translation={translation.data} />
|
||||
</ListItemHoverOverlay>
|
||||
</div>
|
||||
</div>
|
||||
<EntryItemWrapper
|
||||
itemClassName={Item.wrapperClassName}
|
||||
entry={entry}
|
||||
view={view}
|
||||
overlay
|
||||
>
|
||||
<Item entryId={entry.entries.id} translation={translation.data} />
|
||||
</EntryItemWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -251,21 +144,3 @@ const createSkeletonItems = (element: ReactNode) => {
|
|||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export const EntryItemSkeletonWithDelayShow: FC<{
|
||||
view: FeedViewType
|
||||
}> = memo(({ view }) => {
|
||||
const SkeletonItem = SkeletonItemMap[view]
|
||||
|
||||
return SkeletonItem ?
|
||||
(
|
||||
<div
|
||||
className={cn(views[view].gridMode ? girdClassNames : "flex flex-col")}
|
||||
>
|
||||
{createSkeletonItems(SkeletonItem)}
|
||||
</div>
|
||||
) :
|
||||
(
|
||||
LoadingCircleFallback
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
|
||||
import { ListItemHoverOverlay } from "@renderer/components/ui/list-item-hover-overlay"
|
||||
import { views } from "@renderer/constants"
|
||||
import { useAsRead } from "@renderer/hooks/biz/useAsRead"
|
||||
import { useEntryActions } from "@renderer/hooks/biz/useEntryActions"
|
||||
import { useFeedActions } from "@renderer/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FlatEntryModel } from "@renderer/store/entry"
|
||||
import { entryActions } from "@renderer/store/entry"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
export const EntryItemWrapper: FC<
|
||||
{
|
||||
entry: FlatEntryModel
|
||||
view?: number
|
||||
itemClassName?: string
|
||||
overlay?: boolean
|
||||
} & PropsWithChildren
|
||||
> = ({ entry, view, overlay, children, itemClassName }) => {
|
||||
const { items } = useEntryActions({
|
||||
view,
|
||||
entry,
|
||||
})
|
||||
|
||||
const { items: feedItems } = useFeedActions({
|
||||
feedId: entry.feedId,
|
||||
view,
|
||||
type: "entryList",
|
||||
})
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
({ entryId }) => entryId === entry.entries.id,
|
||||
)
|
||||
|
||||
const asRead = useAsRead(entry)
|
||||
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
|
||||
|
||||
const handleMouseEnter = useDebounceCallback(
|
||||
() => {
|
||||
if (!hoverMarkUnread) return
|
||||
if (!document.hasFocus()) return
|
||||
if (asRead) return
|
||||
|
||||
entryActions.markRead(entry.feedId, entry.entries.id, true)
|
||||
},
|
||||
233,
|
||||
{
|
||||
leading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
navigate({
|
||||
entryId: entry.entries.id,
|
||||
})
|
||||
},
|
||||
[entry.entries.id, navigate],
|
||||
)
|
||||
const handleDoubleClick: React.MouseEventHandler<HTMLDivElement> =
|
||||
useCallback(
|
||||
() => entry.entries.url && window.open(entry.entries.url, "_blank"),
|
||||
[entry.entries.url],
|
||||
)
|
||||
const handleContextMenu: React.MouseEventHandler<HTMLDivElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
showNativeMenu(
|
||||
[
|
||||
...items
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: item.onClick,
|
||||
shortcut: item.shortcut,
|
||||
})),
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
...feedItems.filter((item) => !item.disabled),
|
||||
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: "Copy Entry ID",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(entry.entries.id)
|
||||
},
|
||||
},
|
||||
],
|
||||
e,
|
||||
)
|
||||
},
|
||||
[items, feedItems, entry.entries.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-entry-id={entry.entries.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md bg-theme-background transition-colors",
|
||||
asRead ?
|
||||
"text-zinc-700 dark:text-neutral-400" :
|
||||
"text-zinc-900 dark:text-neutral-300",
|
||||
itemClassName,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{
|
||||
overlay ? (
|
||||
<ListItemHoverOverlay
|
||||
className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")}
|
||||
isActive={isActive}
|
||||
>
|
||||
{children}
|
||||
</ListItemHoverOverlay>
|
||||
) : children
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ import {
|
|||
setGeneralSetting,
|
||||
useGeneralSettingKey,
|
||||
} from "@renderer/atoms/settings/general"
|
||||
import { setUISetting, useUISettingKey } from "@renderer/atoms/settings/ui"
|
||||
import { useWhoami } from "@renderer/atoms/user"
|
||||
import { ImpressionView } from "@renderer/components/common/ImpressionTracker"
|
||||
import { ActionButton } from "@renderer/components/ui/button"
|
||||
import { DividerVertical } from "@renderer/components/ui/divider"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@renderer/components/ui/typography"
|
||||
|
|
@ -12,19 +14,17 @@ import {
|
|||
views,
|
||||
} from "@renderer/constants"
|
||||
import { shortcuts } from "@renderer/constants/shortcuts"
|
||||
import {
|
||||
useRouteParms,
|
||||
} from "@renderer/hooks/biz/useRouteParams"
|
||||
import { useRouteParms } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { useIsOnline } from "@renderer/hooks/common"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn, getOS, isBizId } from "@renderer/lib/utils"
|
||||
import { useAIDailyReportModal } from "@renderer/modules/ai/ai-daily/hooks"
|
||||
import { EntryHeader } from "@renderer/modules/entry-content/header"
|
||||
import { useRefreshFeedMutation } from "@renderer/queries/feed"
|
||||
import { useFeedById, useFeedHeaderTitle } from "@renderer/store/feed"
|
||||
import type { FC } from "react"
|
||||
|
||||
import { useAIDailyReportModal } from "../ai/ai-daily/hooks"
|
||||
import { EntryHeader } from "../entry-content/header"
|
||||
import { MarkAllButton } from "./mark-all-button"
|
||||
import { MarkAllButton } from "../components/mark-all-button"
|
||||
|
||||
export const EntryListHeader: FC<{
|
||||
totalCount: number
|
||||
|
|
@ -113,6 +113,7 @@ export const EntryListHeader: FC<{
|
|||
)}
|
||||
|
||||
{view === FeedViewType.SocialMedia && <DailyReportButton />}
|
||||
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
|
||||
|
||||
{isOnline ? (
|
||||
feed?.ownerUserId === user?.id && isBizId(routerParams.feedId!) ?
|
||||
|
|
@ -170,11 +171,37 @@ export const EntryListHeader: FC<{
|
|||
const DailyReportButton: FC = () => {
|
||||
const present = useAIDailyReportModal()
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={present}
|
||||
tooltip="Daily Report"
|
||||
>
|
||||
<ActionButton onClick={present} tooltip="Daily Report">
|
||||
<i className="i-mgc-magic-2-cute-re" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const SwitchToMasonryButton = () => {
|
||||
const isMasonry = useUISettingKey("pictureViewMasonry")
|
||||
return (
|
||||
<ImpressionView
|
||||
event="Switch to Masonry"
|
||||
properties={{
|
||||
masonry: isMasonry ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewMasonry", !isMasonry)
|
||||
window.posthog?.capture("Switch to Masonry", {
|
||||
masonry: !isMasonry ? 1 : 0,
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={`Switch to ${isMasonry ? "Grid" : "Masonry"}`}
|
||||
>
|
||||
<i
|
||||
className={cn(
|
||||
!isMasonry ? "i-mgc-grid-cute-re" : "i-mgc-grid-2-cute-re",
|
||||
)}
|
||||
/>
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { forwardRef, memo, useCallback } from "react"
|
|||
import type { VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
|
||||
import { GroupedVirtuoso, Virtuoso } from "react-virtuoso"
|
||||
|
||||
import { DateItem } from "./date-item"
|
||||
import { DateItem } from "./components/DateItem"
|
||||
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
|
||||
|
||||
export const EntryListContent = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import { SwipeMedia } from "@renderer/components/ui/media/swipe-media"
|
||||
import { ReactVirtuosoItemPlaceholder } from "@renderer/components/ui/placeholder"
|
||||
import { Skeleton } from "@renderer/components/ui/skeleton"
|
||||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { GridItem } from "@renderer/modules/entry-column/grid-item-template"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
|
||||
import { usePreviewMedia } from "../../components/ui/media/hooks"
|
||||
import type { UniversalItemProps } from "./types"
|
||||
|
||||
export function PictureItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
const entry = useEntry(entryId) || entryPreview
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
({ entryId }) => entryId === entry?.entries.id,
|
||||
)
|
||||
|
||||
const previewMedia = usePreviewMedia()
|
||||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
return (
|
||||
<GridItem
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
>
|
||||
<div className="relative flex gap-2 overflow-x-auto">
|
||||
{entry.entries.media ? (
|
||||
<SwipeMedia
|
||||
media={entry.entries.media}
|
||||
className={cn(
|
||||
"aspect-square w-full shrink-0 rounded-md",
|
||||
isActive && "rounded-b-none",
|
||||
)}
|
||||
imgClassName="object-cover"
|
||||
uniqueKey={entryId}
|
||||
onPreview={(media, i) => {
|
||||
previewMedia(media, i)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="center aspect-square w-full flex-col gap-1 bg-muted text-xs text-muted-foreground">
|
||||
<i className="i-mgc-sad-cute-re size-6" />
|
||||
No media available
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</GridItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const PictureItemSkeleton = (
|
||||
<div className="relative max-w-md rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative">
|
||||
<div className="p-1.5">
|
||||
<div className="relative flex gap-2 overflow-x-auto">
|
||||
<div className="relative flex aspect-square w-full shrink-0 items-center overflow-hidden rounded-md">
|
||||
<Skeleton className="size-full overflow-hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
|
||||
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
|
||||
<Skeleton className="h-4 w-3/4 " />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
|
||||
<Skeleton className="mr-0.5 size-4" />
|
||||
<Skeleton className="h-3 w-1/2 " />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<Skeleton className="h-3 w-12 " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Mark as read has three logic now.
|
||||
|
||||
- Rendering entry as read.
|
||||
- Hover entry as read
|
||||
- Scrolling entry as read
|
||||
|
|
@ -8,16 +8,18 @@ import { useEntry } from "@renderer/store/entry/hooks"
|
|||
import { useFeedById } from "@renderer/store/feed"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
import { StarIcon } from "./star-icon"
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import { StarIcon } from "../star-icon"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
export function GridItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
children,
|
||||
wrapperClassName,
|
||||
}: UniversalItemProps & {
|
||||
children?: React.ReactNode
|
||||
wrapperClassName?: string
|
||||
}) {
|
||||
const entry = useEntry(entryId) || entryPreview
|
||||
const feeds = useFeedById(entry?.feedId)
|
||||
|
|
@ -26,11 +28,11 @@ export function GridItem({
|
|||
|
||||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
return (
|
||||
<div className="p-1.5">
|
||||
<div className={cn("p-1.5", wrapperClassName)}>
|
||||
{children}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex-1 px-2 py-1 text-sm",
|
||||
"relative px-2 py-1 text-sm",
|
||||
!asRead &&
|
||||
"before:absolute before:-left-0.5 before:top-[14.5px] before:block before:size-1.5 before:rounded-full before:bg-theme-accent",
|
||||
)}
|
||||
|
|
@ -57,7 +59,7 @@ export function GridItem({
|
|||
entry={entry.entries}
|
||||
size={18}
|
||||
/>
|
||||
<span>{feeds?.title}</span>
|
||||
<span className="min-w-0 truncate">{feeds?.title}</span>
|
||||
<span className="text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">
|
||||
{dayjs
|
||||
|
|
@ -10,9 +10,9 @@ import { EntryTranslation } from "@renderer/modules/entry-column/translation"
|
|||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import { useFeedById } from "@renderer/store/feed"
|
||||
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
|
||||
import { StarIcon } from "./star-icon"
|
||||
import type { UniversalItemProps } from "./types"
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
|
||||
import { StarIcon } from "../star-icon"
|
||||
import type { UniversalItemProps } from "../types"
|
||||
|
||||
export function ListItem({
|
||||
entryId,
|
||||
|
|
@ -102,6 +102,7 @@ export function FeedColumn({ children }: PropsWithChildren) {
|
|||
spring.jump(-active * jotaiStore.get(carouselWidthAtom))
|
||||
}
|
||||
const dispose = jotaiStore.sub(carouselWidthAtom, handler)
|
||||
|
||||
spring.set(-active * jotaiStore.get(carouselWidthAtom))
|
||||
return () => {
|
||||
dispose()
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { views } from "@renderer/constants"
|
|||
import { useTitle } from "@renderer/hooks/common"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn, pluralize } from "@renderer/lib/utils"
|
||||
import { ArticleItem } from "@renderer/modules/entry-column/article-item"
|
||||
import { NotificationItem } from "@renderer/modules/entry-column/notification-item"
|
||||
import { PictureItem } from "@renderer/modules/entry-column/picture-item"
|
||||
import { SocialMediaItem } from "@renderer/modules/entry-column/social-media-item"
|
||||
import { ArticleItem } from "@renderer/modules/entry-column/Items/article-item"
|
||||
import { NotificationItem } from "@renderer/modules/entry-column/Items/notification-item"
|
||||
import { PictureItem } from "@renderer/modules/entry-column/Items/picture-item"
|
||||
import { SocialMediaItem } from "@renderer/modules/entry-column/Items/social-media-item"
|
||||
import { VideoItem } from "@renderer/modules/entry-column/Items/video-item"
|
||||
import type { UniversalItemProps } from "@renderer/modules/entry-column/types"
|
||||
import { VideoItem } from "@renderer/modules/entry-column/video-item"
|
||||
import { useEntriesPreview } from "@renderer/queries/entries"
|
||||
import { useFeed } from "@renderer/queries/feed"
|
||||
import { DEEPLINK_SCHEME } from "@shared/constants"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
getEntriesParams,
|
||||
omitObjectUndefinedValue,
|
||||
} from "@renderer/lib/utils"
|
||||
import type { CombinedEntryModel, EntryModel, FeedModel, UserModel,
|
||||
import type {
|
||||
CombinedEntryModel,
|
||||
EntryModel,
|
||||
FeedModel,
|
||||
UserModel,
|
||||
} from "@renderer/models"
|
||||
import { EntryService } from "@renderer/services"
|
||||
import { produce } from "immer"
|
||||
|
|
@ -12,6 +16,7 @@ import { merge, omit } from "lodash-es"
|
|||
import type { EntryReadHistoriesModel } from "src/hono"
|
||||
|
||||
import { feedActions } from "../feed"
|
||||
import { imageActions } from "../image"
|
||||
import { feedUnreadActions } from "../unread"
|
||||
import { userActions } from "../user"
|
||||
import { createZustandStore, doMutationAndTransaction } from "../utils/helper"
|
||||
|
|
@ -121,10 +126,14 @@ class EntryActions {
|
|||
)
|
||||
}
|
||||
|
||||
patchManyByFeedId(feedId: string, changed: Partial<CombinedEntryModel>, filter?: {
|
||||
startTime: number
|
||||
endTime: number
|
||||
}) {
|
||||
patchManyByFeedId(
|
||||
feedId: string,
|
||||
changed: Partial<CombinedEntryModel>,
|
||||
filter?: {
|
||||
startTime: number
|
||||
endTime: number
|
||||
},
|
||||
) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const ids = draft.entries[feedId]
|
||||
|
|
@ -157,17 +166,6 @@ class EntryActions {
|
|||
feedUnreadActions.updateByFeedId(feedId, 0)
|
||||
}
|
||||
|
||||
private patchAll(changed: Partial<CombinedEntryModel>) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
for (const entry of Object.values(draft.flatMapEntries)) {
|
||||
Object.assign(entry, changed)
|
||||
}
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
upsertMany(data: CombinedEntryModel[]) {
|
||||
const feeds = [] as FeedModel[]
|
||||
const entries = [] as EntryModel[]
|
||||
|
|
@ -224,6 +222,20 @@ class EntryActions {
|
|||
if (item.collections) {
|
||||
entryCollection[item.entries.id] = item.collections
|
||||
}
|
||||
|
||||
if (item.entries.media) {
|
||||
for (const media of item.entries.media) {
|
||||
if (!media.height || !media.width) continue
|
||||
imageActions.saveImages([
|
||||
{
|
||||
src: media.url,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
ratio: media.width / media.height,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return draft
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { createStore, get, set } from "idb-keyval"
|
||||
|
||||
import type { StoreImageType } from "."
|
||||
|
||||
const db = createStore("FOLLOW_IMAGE_DIMENSIONS", "image-dimensions")
|
||||
|
||||
export const getImageDimensionsFromDb = async (url: string) => await get(url, db)
|
||||
|
||||
export const saveImageDimensionsToDb = async (
|
||||
url: string,
|
||||
dimensions: StoreImageType,
|
||||
) => await set(url, dimensions, db)
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { EntryModel } from "@renderer/models"
|
||||
|
||||
import { createZustandStore } from "../utils/helper"
|
||||
import { getImageDimensionsFromDb } from "./db"
|
||||
|
||||
export interface StoreImageType {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
ratio: number
|
||||
}
|
||||
interface State {
|
||||
images: Record<string, StoreImageType>
|
||||
}
|
||||
export const useImageStore = createZustandStore<State>("image")(() => ({
|
||||
images: {},
|
||||
}))
|
||||
|
||||
const set = useImageStore.setState
|
||||
const get = useImageStore.getState
|
||||
|
||||
class ImageActions {
|
||||
getImage(src: string) {
|
||||
return get().images[src]
|
||||
}
|
||||
|
||||
saveImages(images: StoreImageType[]) {
|
||||
set((state) => {
|
||||
const newImages = { ...state.images }
|
||||
for (const image of images) {
|
||||
newImages[image.src] = image
|
||||
}
|
||||
return { images: newImages }
|
||||
})
|
||||
}
|
||||
|
||||
async fetchDimensionsFromDb(images: string[]) {
|
||||
const dims = [] as StoreImageType[]
|
||||
for (const image of images) {
|
||||
const dbData = await getImageDimensionsFromDb(image)
|
||||
if (dbData) {
|
||||
dims.push(dbData)
|
||||
}
|
||||
}
|
||||
imageActions.saveImages(dims)
|
||||
}
|
||||
|
||||
getImagesFromEntry(entry: EntryModel) {
|
||||
const images = [] as string[]
|
||||
if (!entry.media) return images
|
||||
for (const media of entry.media) {
|
||||
if (media.type === "photo") {
|
||||
images.push(media.url)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
}
|
||||
export const imageActions = new ImageActions()
|
||||
/// // HOOKS
|
||||
export const useImageDimensions = (url: string) =>
|
||||
useImageStore((state) => state.images[url])
|
||||
export const useImagesHasDimensions = (urls?: string[]) =>
|
||||
useImageStore((state) =>
|
||||
urls ? urls?.every((url) => state.images[url]) : false,
|
||||
)
|
||||
|
|
@ -24,4 +24,7 @@ export interface UISettings {
|
|||
readerFontFamily: string
|
||||
readerRenderInlineStyle: boolean
|
||||
codeHighlightTheme: string
|
||||
|
||||
// view
|
||||
pictureViewMasonry: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
export class AsyncQueue {
|
||||
private maxConcurrent: number
|
||||
private queue: (() => Promise<any>)[]
|
||||
private activeCount: number
|
||||
|
||||
constructor(maxConcurrent: number) {
|
||||
this.maxConcurrent = maxConcurrent
|
||||
this.queue = []
|
||||
this.activeCount = 0
|
||||
}
|
||||
|
||||
private async runNext() {
|
||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.activeCount++
|
||||
const request = this.queue.shift()!
|
||||
|
||||
try {
|
||||
return await request()
|
||||
} catch (error) {
|
||||
console.error("Request failed", error)
|
||||
} finally {
|
||||
this.activeCount--
|
||||
this.runNext() // Start the next request after this one finishes
|
||||
}
|
||||
}
|
||||
|
||||
add(request: () => Promise<any>) {
|
||||
this.queue.push(request)
|
||||
return this.runNext()
|
||||
}
|
||||
|
||||
addMultiple(requests: (() => Promise<any>)[]) {
|
||||
this.queue.push(...requests)
|
||||
this.runNext()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue