feat: add entry item skeleton
Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
8953b7f6ca
commit
d11cc27985
|
|
@ -16,6 +16,7 @@ export const TitleMarquee = ({
|
|||
return (
|
||||
<div
|
||||
ref={$wrapper}
|
||||
// className="[&_*]:scrollbar-none"
|
||||
onMouseEnter={useCallback(() => {
|
||||
const $container = ref.current
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ export const TitleMarquee = ({
|
|||
})
|
||||
}, [])}
|
||||
>
|
||||
<Marquee play={hovered} ref={ref} speed={speed} {...rest}>
|
||||
<Marquee className="overflow-hidden" play={hovered} ref={ref} speed={speed} {...rest}>
|
||||
{children}
|
||||
</Marquee>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export const useFeedActions = ({
|
|||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: "Copy Feed Id",
|
||||
label: "Copy Feed ID",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(feedId)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,43 @@ import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
|||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
|
||||
export function ArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) {
|
||||
export function ArticleItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
return (
|
||||
<ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} withDetails />
|
||||
<ListItem
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
withDetails
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ArticleItemSkeleton = (
|
||||
<div className="relative h-[120px] rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative z-[1]">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<div className="mr-2 size-5 animate-pulse rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
|
||||
<div className="flex gap-1 text-[10px] font-bold text-zinc-400 dark:text-neutral-500">
|
||||
<div className="h-3 w-24 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<span>·</span>
|
||||
<div className="h-3 w-12 shrink-0 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="relative my-1 break-words font-medium">
|
||||
<div className="h-3.5 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-1 h-3.5 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="mt-1.5 text-[13px] text-zinc-400 dark:text-neutral-500">
|
||||
<div className="h-3 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-1 h-3 w-4/5 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 size-20 animate-pulse overflow-hidden rounded bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,40 @@ import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
|||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
|
||||
export function AudioItem({ entryId, entryPreview, translation }: UniversalItemProps) {
|
||||
export function AudioItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
return (
|
||||
<ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} withAudio />
|
||||
<ListItem
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
withAudio
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AudioItemSkeleton = (
|
||||
<div className="relative mx-auto w-full max-w-lg rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative z-[1]">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
|
||||
<div className="flex gap-1 text-[10px] font-bold text-zinc-400 dark:text-neutral-500">
|
||||
<div className="h-3 w-20 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<span>·</span>
|
||||
<div className="h-3 w-10 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="relative my-0.5 break-words font-medium">
|
||||
<div className="h-4 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-2 h-4 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative ml-2 size-20 shrink-0">
|
||||
<div className="mr-2 size-20 shrink-0 animate-pulse overflow-hidden rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ import type { VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
|
|||
import { Virtuoso, VirtuosoGrid } from "react-virtuoso"
|
||||
|
||||
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
|
||||
import { EntryItem } from "./item"
|
||||
import { EntryItem, EntryItemSkeleton } from "./item"
|
||||
|
||||
export function EntryColumn() {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
|
|
@ -98,12 +98,8 @@ export function EntryColumn() {
|
|||
|
||||
Footer: useCallback(() => {
|
||||
if (!isFetchingNextPage) return null
|
||||
return (
|
||||
<div className="center mt-2">
|
||||
<LoadingCircle size="medium" />
|
||||
</div>
|
||||
)
|
||||
}, [isFetchingNextPage]),
|
||||
return <EntryItemSkeleton view={view} />
|
||||
}, [isFetchingNextPage, view]),
|
||||
},
|
||||
rangeChanged: (...args: any[]) => {
|
||||
handleMarkReadInRange &&
|
||||
|
|
@ -126,7 +122,7 @@ export function EntryColumn() {
|
|||
},
|
||||
[view],
|
||||
),
|
||||
}
|
||||
} satisfies VirtuosoProps<string, unknown>
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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"
|
||||
|
|
@ -14,18 +15,21 @@ 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 } from "react"
|
||||
import type { FC, ReactNode } from "react"
|
||||
import { memo, useCallback } from "react"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
|
||||
import { ArticleItem } from "./article-item"
|
||||
import { AudioItem } from "./audio-item"
|
||||
import { NotificationItem } from "./notification-item"
|
||||
import { PictureItem } from "./picture-item"
|
||||
import { SocialMediaItem } from "./social-media-item"
|
||||
import { ArticleItem, ArticleItemSkeleton } from "./article-item"
|
||||
import { AudioItem, AudioItemSkeleton } from "./audio-item"
|
||||
import {
|
||||
NotificationItem,
|
||||
NotificationItemSkeleton,
|
||||
} from "./notification-item"
|
||||
import { PictureItem, PictureItemSkeleton } from "./picture-item"
|
||||
import { SocialMediaItem, SocialMediaItemSkeleton } from "./social-media-item"
|
||||
import type { EntryListItemFC } from "./types"
|
||||
import { VideoItem } from "./video-item"
|
||||
import { VideoItem, VideoItemSkeleton } from "./video-item"
|
||||
|
||||
interface EntryItemProps {
|
||||
entryId: string
|
||||
|
|
@ -139,25 +143,25 @@ function EntryItemImpl({
|
|||
e.preventDefault()
|
||||
showNativeMenu(
|
||||
[
|
||||
...(items
|
||||
...items
|
||||
.filter((item) => !item.disabled)
|
||||
.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: item.onClick,
|
||||
shortcut: item.shortcut,
|
||||
}))),
|
||||
})),
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
...(feedItems.filter((item) => !item.disabled)),
|
||||
...feedItems.filter((item) => !item.disabled),
|
||||
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: "Copy Entry Id",
|
||||
label: "Copy Entry ID",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(entry.entries.id)
|
||||
},
|
||||
|
|
@ -170,9 +174,7 @@ function EntryItemImpl({
|
|||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-entry-id={entry.entries.id}
|
||||
>
|
||||
<div data-entry-id={entry.entries.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md bg-theme-background transition-colors",
|
||||
|
|
@ -186,7 +188,11 @@ function EntryItemImpl({
|
|||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<ListItemHoverOverlay className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")} isActive={isActive}>
|
||||
|
||||
<ListItemHoverOverlay
|
||||
className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Item entryId={entry.entries.id} translation={translation.data} />
|
||||
</ListItemHoverOverlay>
|
||||
</div>
|
||||
|
|
@ -199,3 +205,38 @@ export const EntryItem: FC<EntryItemProps> = memo(({ entryId, view }) => {
|
|||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
return <EntryItemImpl entry={entry} view={view} />
|
||||
})
|
||||
|
||||
const SkeletonItemMap = {
|
||||
[FeedViewType.Articles]: ArticleItemSkeleton,
|
||||
[FeedViewType.SocialMedia]: SocialMediaItemSkeleton,
|
||||
[FeedViewType.Pictures]: PictureItemSkeleton,
|
||||
[FeedViewType.Videos]: VideoItemSkeleton,
|
||||
[FeedViewType.Audios]: AudioItemSkeleton,
|
||||
[FeedViewType.Notifications]: NotificationItemSkeleton,
|
||||
}
|
||||
|
||||
const LoadingCircleFallback = (
|
||||
<div className="center mt-2">
|
||||
<LoadingCircle size="medium" />
|
||||
</div>
|
||||
)
|
||||
export const EntryItemSkeleton: FC<{ view: FeedViewType }> = memo(
|
||||
({ view }) => {
|
||||
const SkeletonItem = SkeletonItemMap[view]
|
||||
return SkeletonItem ?
|
||||
(
|
||||
<div className="flex flex-col">{createSkeletonItems(SkeletonItem)}</div>
|
||||
) :
|
||||
(
|
||||
LoadingCircleFallback
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const createSkeletonItems = (element: ReactNode) => {
|
||||
const children = [] as ReactNode[]
|
||||
for (let i = 0; i < 10; i++) {
|
||||
children.push(element)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,35 @@ import { ListItem } from "@renderer/modules/entry-column/list-item-template"
|
|||
|
||||
import type { UniversalItemProps } from "./types"
|
||||
|
||||
export function NotificationItem({ entryId, entryPreview, translation }: UniversalItemProps) {
|
||||
export function NotificationItem({
|
||||
entryId,
|
||||
entryPreview,
|
||||
translation,
|
||||
}: UniversalItemProps) {
|
||||
return (
|
||||
<ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} />
|
||||
<ListItem
|
||||
entryId={entryId}
|
||||
entryPreview={entryPreview}
|
||||
translation={translation}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotificationItemSkeleton = (
|
||||
<div className="relative z-[1] mx-auto w-full max-w-lg">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<div className="mr-2 size-5 shrink-0 animate-pulse overflow-hidden rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
|
||||
<div className="flex gap-1 text-[10px] font-bold text-zinc-400 dark:text-neutral-500">
|
||||
<div className="h-3 w-32 animate-pulse truncate bg-gray-200 dark:bg-neutral-800" />
|
||||
<span>·</span>
|
||||
<div className="h-3 w-12 shrink-0 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="relative my-0.5 break-words">
|
||||
<div className="h-4 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-2 h-4 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,3 +44,28 @@ export function PictureItem({
|
|||
</GridItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const PictureItemSkeleton = (
|
||||
<div className="relative mx-auto w-full max-w-md rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative z-[1]">
|
||||
<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">
|
||||
<div className="size-full animate-pulse overflow-hidden rounded-none bg-gray-200 dark:bg-neutral-800" />
|
||||
</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">
|
||||
<div className="h-4 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
|
||||
<div className="mr-0.5 size-4 animate-pulse rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/2 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-3 w-12 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import { Media } from "@renderer/components/ui/media"
|
|||
import { usePreviewMedia } from "@renderer/components/ui/media/hooks"
|
||||
import { useAsRead } from "@renderer/hooks/biz/useAsRead"
|
||||
import { useEntryActions } from "@renderer/hooks/biz/useEntryActions"
|
||||
import {
|
||||
useRouteParamsSelector,
|
||||
} from "@renderer/hooks/biz/useRouteParams"
|
||||
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import { useFeedById } from "@renderer/store/feed"
|
||||
|
|
@ -136,3 +134,39 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SocialMediaItemSkeleton = (
|
||||
<div className="relative m-auto w-[75ch] rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative z-[1]">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<div className="mr-2 size-9 animate-pulse rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="ml-2 min-w-0 flex-1">
|
||||
<div className="-mt-0.5 line-clamp-5 flex-1 text-sm">
|
||||
<div className="flex w-[calc(100%-10rem)] space-x-1">
|
||||
<div className="h-4 w-16 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-12 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="relative mt-0.5 whitespace-pre-line text-base">
|
||||
<div className="h-4 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-1.5 h-4 w-full animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="mt-1.5 h-4 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2 overflow-x-auto">
|
||||
<div className="size-28 animate-pulse overflow-hidden rounded bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="invisible absolute right-1 top-1.5 opacity-0 duration-200 group-hover:visible group-hover:opacity-80">
|
||||
<div className="flex origin-right scale-90 items-center gap-1">
|
||||
<div className="size-8 animate-pulse justify-center rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="size-8 animate-pulse justify-center rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="size-8 animate-pulse justify-center rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="size-8 animate-pulse justify-center rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="size-8 animate-pulse justify-center rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -137,3 +137,28 @@ const PreviewVideoModalContent: ModalContentComponent<{
|
|||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VideoItemSkeleton = (
|
||||
<div className="relative mx-auto w-full max-w-lg rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative z-[1]">
|
||||
<div className="p-1.5">
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="aspect-video w-full shrink-0 animate-pulse overflow-hidden rounded-md bg-gray-200 dark:bg-neutral-800" />
|
||||
</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">
|
||||
<div className="h-4 w-3/4 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
|
||||
<div className="mr-0.5 size-4 animate-pulse rounded-sm bg-gray-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/2 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-3 w-12 animate-pulse bg-gray-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -261,3 +261,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue