feat: picture entry preview modal

This commit is contained in:
DIYgod 2024-09-27 04:41:04 +08:00
parent 8e6021740f
commit 29f2c0ca41
No known key found for this signature in database
7 changed files with 98 additions and 80 deletions

View File

@ -27,8 +27,6 @@ import { cn } from "~/lib/utils"
import { MotionButtonBase } from "../button"
import { softSpringPreset } from "../constants/spring"
import { KbdCombined } from "../kbd/Kbd"
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"
import { VolumeSlider } from "./VolumeSlider"
type VideoPlayerProps = {
@ -225,7 +223,7 @@ const ControlBar = memo(() => {
dragMomentum={false}
dragConstraints={{ current: document.documentElement }}
className={cn(
"absolute inset-x-2 -bottom-10 h-8 rounded-2xl border bg-zinc-100/90 backdrop-blur-xl dark:border-transparent dark:bg-neutral-700/90",
"absolute inset-x-2 bottom-2 h-8 rounded-2xl border bg-zinc-100/90 backdrop-blur-xl dark:border-transparent dark:bg-neutral-700/90",
"flex items-center gap-3 px-3",
"mx-auto max-w-[80vw]",
)}
@ -423,8 +421,6 @@ const PlayProgressBar = () => {
const ActionIcon = ({
className,
onClick,
label,
labelDelayDuration = 700,
children,
shortcut,
}: {
@ -446,20 +442,12 @@ const ActionIcon = ({
},
)
return (
<Tooltip delayDuration={labelDelayDuration}>
<TooltipTrigger asChild>
<button
type="button"
className="center relative z-[1] size-6 rounded-md hover:bg-theme-button-hover"
onClick={onClick}
>
{children || <i className={className} />}
</button>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 text-xs">
{label}
{shortcut && <KbdCombined>{shortcut}</KbdCombined>}
</TooltipContent>
</Tooltip>
<button
type="button"
className="center relative z-[1] size-6 rounded-md hover:bg-theme-button-hover"
onClick={onClick}
>
{children || <i className={className} />}
</button>
)
}

View File

@ -5,14 +5,14 @@ import { useModalStack } from "../modal/stacked/hooks"
import type { PreviewMediaProps } from "./preview-media"
import { PreviewMediaContent } from "./preview-media"
export const usePreviewMedia = () => {
export const usePreviewMedia = (entryId?: string) => {
const { present } = useModalStack()
return useCallback(
(media: PreviewMediaProps[], initialIndex = 0) => {
present({
content: () => (
<div className="relative size-full">
<PreviewMediaContent initialIndex={initialIndex} media={media} />
<PreviewMediaContent initialIndex={initialIndex} media={media} entryId={entryId} />
</div>
),
title: "Media Preview",

View File

@ -11,6 +11,7 @@ import { tipcClient } from "~/lib/client"
import { stopPropagation } from "~/lib/dom"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { cn } from "~/lib/utils"
import { EntryContent } from "~/modules/entry-content"
import { ActionButton, MotionButtonBase } from "../button"
import { microReboundPreset } from "../constants/spring"
@ -20,55 +21,61 @@ import { VideoPlayer } from "./VideoPlayer"
const Wrapper: Component<{
src: string
showActions?: boolean
}> = ({ children, src, showActions }) => {
entryId?: string
}> = ({ children, src, showActions, entryId }) => {
const { dismiss } = useCurrentModal()
return (
<div className="center relative size-full p-12" onClick={dismiss}>
<div className="center relative size-full px-20 pb-8 pt-10" onClick={dismiss}>
<m.div
className="center size-full"
className="center flex size-full"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.94, opacity: 0 }}
transition={microReboundPreset}
>
{children}
</m.div>
<m.div
initial={{ opacity: 0.8 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute bottom-4 right-4 flex gap-3 text-white/70 [&_button]:hover:text-white"
onClick={stopPropagation}
>
<button
onClick={dismiss}
className="center fixed right-6 top-6 size-8 rounded-full border border-white/20 bg-neutral-900 text-white"
type="button"
<div
className={cn(
"relative flex h-full w-auto overflow-hidden",
entryId ? "rounded-l-xl bg-native" : "rounded-xl",
)}
>
<i className="i-mgc-close-cute-re" />
</button>
{showActions && (
<Fragment>
{!!window.electron && (
<ActionButton
tooltip="Download"
onClick={() => {
tipcClient?.download(src)
}}
>
<i className="i-mgc-download-2-cute-re" />
</ActionButton>
{children}
<div
className="absolute bottom-4 right-4 z-[99] flex gap-3 text-white/70 [&_button]:hover:text-white"
onClick={stopPropagation}
>
{showActions && (
<Fragment>
{!!window.electron && (
<ActionButton
tooltip="Download"
onClick={() => {
tipcClient?.download(src)
}}
>
<i className="i-mgc-download-2-cute-re" />
</ActionButton>
)}
<ActionButton
tooltip={COPY_MAP.OpenInBrowser()}
onClick={() => {
window.open(src)
}}
>
<i className="i-mgc-external-link-cute-re" />
</ActionButton>
</Fragment>
)}
<ActionButton
tooltip={COPY_MAP.OpenInBrowser()}
onClick={() => {
window.open(src)
}}
>
<i className="i-mgc-external-link-cute-re" />
</ActionButton>
</Fragment>
</div>
</div>
{entryId && (
<div
className="box-border flex h-full w-[400px] min-w-0 shrink-0 flex-col rounded-r-xl bg-white px-2 pt-1"
onClick={stopPropagation}
>
<EntryContent entryId={entryId} noMedia compact />
</div>
)}
</m.div>
</div>
@ -81,7 +88,8 @@ export interface PreviewMediaProps extends MediaModel {
export const PreviewMediaContent: FC<{
media: PreviewMediaProps[]
initialIndex?: number
}> = ({ media, initialIndex = 0 }) => {
entryId?: string
}> = ({ media, initialIndex = 0, entryId }) => {
const [currentMedia, setCurrentMedia] = useState(media[initialIndex])
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)
const swiperRef = useRef<SwiperRef>(null)
@ -100,20 +108,21 @@ export const PreviewMediaContent: FC<{
const src = media[0].url
const { type } = media[0]
return (
<Wrapper src={src} showActions={type !== "video"}>
<Wrapper src={src} showActions={type !== "video"} entryId={entryId}>
{type === "video" ? (
<VideoPlayer
src={src}
controls
autoPlay
muted
className="size-full object-contain"
className={cn("h-full w-auto object-contain", entryId && "rounded-l-xl")}
onClick={stopPropagation}
/>
) : (
<FallbackableImage
fallbackUrl={media[0].fallbackUrl}
className="size-full object-contain"
containerClassName="w-auto"
className="h-full w-auto object-contain"
alt="cover"
src={src}
/>
@ -122,7 +131,7 @@ export const PreviewMediaContent: FC<{
)
}
return (
<Wrapper src={currentMedia.url} showActions={currentMedia.type !== "video"}>
<Wrapper src={currentMedia.url} showActions={currentMedia.type !== "video"} entryId={entryId}>
<Swiper
ref={swiperRef}
loop
@ -138,7 +147,7 @@ export const PreviewMediaContent: FC<{
setCurrentSlideIndex(realIndex)
}}
modules={[Mousewheel, Keyboard]}
className="size-full"
className="h-full w-auto"
>
{showActions && (
<div tabIndex={-1} onClick={stopPropagation}>
@ -148,7 +157,7 @@ export const PreviewMediaContent: FC<{
transition={{ ease: "easeInOut", duration: 0.2 }}
onClick={() => swiperRef.current?.swiper.slidePrev()}
type="button"
className="center fixed left-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
className="center absolute left-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
>
<i className="i-mingcute-arrow-left-line" />
</m.button>
@ -159,7 +168,7 @@ export const PreviewMediaContent: FC<{
transition={{ ease: "easeInOut", duration: 0.2 }}
onClick={() => swiperRef.current?.swiper.slideNext()}
type="button"
className="center fixed right-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
className="center absolute right-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
>
<i className="i-mingcute-arrow-right-line" />
</m.button>
@ -168,13 +177,13 @@ export const PreviewMediaContent: FC<{
{showActions && (
<div>
<div className="fixed bottom-4 left-4 text-sm tabular-nums text-white/60 animate-in fade-in-0 slide-in-from-bottom-6">
<div className="absolute bottom-4 left-4 text-sm tabular-nums text-white/60 animate-in fade-in-0 slide-in-from-bottom-6">
{currentSlideIndex + 1} / {media.length}
</div>
<div
tabIndex={-1}
onClick={stopPropagation}
className="center fixed bottom-4 left-1/2 h-6 -translate-x-1/2 gap-2 rounded-full bg-neutral-700/90 px-4 duration-200 animate-in fade-in-0 slide-in-from-bottom-6"
className="center absolute bottom-4 left-1/2 z-[99] h-6 -translate-x-1/2 gap-2 rounded-full bg-neutral-700/90 px-4 duration-200 animate-in fade-in-0 slide-in-from-bottom-6"
>
{Array.from({ length: media.length })
.fill(0)
@ -200,6 +209,8 @@ export const PreviewMediaContent: FC<{
{med.type === "video" ? (
<VideoPlayer
src={med.url}
autoPlay
muted
controls
className="size-full object-contain"
onClick={(e) => e.stopPropagation()}
@ -223,9 +234,10 @@ export const PreviewMediaContent: FC<{
const FallbackableImage: FC<
Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src: string
containerClassName?: string
fallbackUrl?: string
}
> = ({ src, onError, fallbackUrl, ...props }) => {
> = ({ src, onError, fallbackUrl, containerClassName, ...props }) => {
const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))
const [isAllError, setIsAllError] = useState(false)
@ -266,7 +278,7 @@ const FallbackableImage: FC<
}, [currentSrc, currentState, fallbackUrl, src])
return (
<div className="flex size-full flex-col">
<div className={cn("flex size-full flex-col", containerClassName)}>
{isLoading && !isAllError && (
<div className="center absolute inset-0 size-full">
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-white/80" />

View File

@ -73,7 +73,7 @@ export const PictureWaterFallItem = memo(function PictureWaterFallItem({
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.entries.id)
const previewMedia = usePreviewMedia()
const previewMedia = usePreviewMedia(entryId)
const itemWidth = useMasonryItemWidth()
const [ref, setRef] = useState<HTMLDivElement | null>(null)

View File

@ -2,6 +2,7 @@ import { useMemo } from "react"
import { useWhoami } from "~/atoms/user"
import { useAuthQuery } from "~/hooks/common"
import { cn } from "~/lib/utils"
import type { FeedModel } from "~/models"
import { Queries } from "~/queries"
import { useEntry, useEntryReadHistory } from "~/store/entry"
@ -11,6 +12,7 @@ import { EntryTranslation } from "../../entry-column/translation"
interface EntryLinkProps {
entryId: string
compact?: boolean
}
const safeUrl = (url: string, baseUrl: string) => {
@ -21,7 +23,7 @@ const safeUrl = (url: string, baseUrl: string) => {
}
}
export const EntryTitle = ({ entryId }: EntryLinkProps) => {
export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const user = useWhoami()
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId) as FeedModel
@ -62,7 +64,7 @@ export const EntryTitle = ({ entryId }: EntryLinkProps) => {
className="-mx-6 block cursor-button rounded-lg p-6 transition-colors hover:bg-theme-item-hover focus-visible:bg-theme-item-hover focus-visible:!outline-none @sm:-mx-3 @sm:p-3"
rel="noreferrer"
>
<div className="select-text break-words text-3xl font-bold">
<div className={cn("select-text break-words font-bold", compact ? "text-2xl" : "text-3xl")}>
<EntryTranslation source={entry.entries.title} target={translation.data?.title} />
</div>
<div className="mt-2 text-[13px] font-medium text-zinc-500">{getPreferredTitle(feed)}</div>

View File

@ -31,10 +31,12 @@ function EntryHeaderImpl({
view,
entryId,
className,
compact,
}: {
view: number
entryId: string
className?: string
compact?: boolean
}) {
const entry = useEntry(entryId)
@ -90,7 +92,7 @@ function EntryHeaderImpl({
</div>
<div className="relative flex shrink-0 items-center justify-end gap-3">
<ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />
{!compact && <ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />}
{items
.filter((item) => !item.hide)

View File

@ -50,7 +50,15 @@ import { EntryHeader } from "./header"
import { EntryContentLoading } from "./loading"
import { EntryContentProvider } from "./provider"
export const EntryContent = ({ entryId }: { entryId: ActiveEntryId }) => {
export const EntryContent = ({
entryId,
noMedia,
compact,
}: {
entryId: ActiveEntryId
noMedia?: boolean
compact?: boolean
}) => {
const title = useFeedHeaderTitle()
const { feedId, view } = useRouteParams()
@ -70,10 +78,14 @@ export const EntryContent = ({ entryId }: { entryId: ActiveEntryId }) => {
)
}
return <EntryContentRender entryId={entryId} />
return <EntryContentRender entryId={entryId} noMedia={noMedia} compact={compact} />
}
export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, className }) => {
export const EntryContentRender: Component<{
entryId: string
noMedia?: boolean
compact?: boolean
}> = ({ entryId, noMedia, className, compact }) => {
const { t } = useTranslation()
const { error, data, isPending } = useAuthQuery(Queries.entries.byId(entryId), {
@ -148,6 +160,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
entryId={entry.entries.id}
view={0}
className="h-[55px] shrink-0 px-3 @container"
compact={compact}
/>
<ScrollArea.ScrollArea
@ -167,7 +180,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @3xl:max-w-[70ch]"
>
<EntryTitle entryId={entryId} />
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem]">
@ -187,6 +200,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em]"