feat: picture entry preview modal
This commit is contained in:
parent
8e6021740f
commit
29f2c0ca41
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
Loading…
Reference in New Issue