アニメーション(#139) #252
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { fetchPost } from '@/lib/posts'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
@@ -15,73 +14,62 @@ type Props = { posts: Post[]
|
||||
|
||||
|
||||
export default (({ posts, onClick }: Props) => {
|
||||
const navigate = useNavigate ()
|
||||
const location = useLocation ()
|
||||
|
||||
const qc = useQueryClient ()
|
||||
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
||||
|
||||
const prefetch = (id: string) => qc.prefetchQuery ({
|
||||
queryKey: ['post', id],
|
||||
queryFn: () => fetchPost (id) })
|
||||
const cardRef = useRef<HTMLDivElement> (null)
|
||||
|
||||
const outboundSharedId = useSharedTransitionStore (s => s.byLocationKey[location.key])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-6 p-4">
|
||||
{posts.map ((post, i) => {
|
||||
const id = String (post.id)
|
||||
const hRef = `/posts/${ id }`
|
||||
const cardRef = useRef<HTMLDivElement> (null)
|
||||
<>
|
||||
<div className="flex flex-wrap gap-6 p-4">
|
||||
{posts.map ((post, i) => {
|
||||
const id2 = `page-${ post.id }`
|
||||
const layoutId = outboundSharedId === id2 ? id2 : undefined
|
||||
|
||||
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(ev)
|
||||
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1)
|
||||
return
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
await qc.ensureQueryData ({
|
||||
queryKey: ['post', id],
|
||||
queryFn: () => fetchPost (id) })
|
||||
|
||||
navigate (hRef)
|
||||
}
|
||||
|
||||
return (
|
||||
<PrefetchLink
|
||||
to={hRef}
|
||||
key={id}
|
||||
className="w-40 h-40"
|
||||
onMouseEnter={() => prefetch (id)}
|
||||
onFocus={() => prefetch (id)}
|
||||
onClick={handleClick}>
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
layoutId={`page-${ id }`}
|
||||
className="w-full h-full overflow-hidden rounded-xl shadow
|
||||
transform-gpu will-change-transform"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onLayoutAnimationStart={() => {
|
||||
if (cardRef.current)
|
||||
{
|
||||
cardRef.current.style.position = 'relative'
|
||||
cardRef.current.style.zIndex = '9999'
|
||||
}
|
||||
}}
|
||||
onLayoutAnimationComplete={() => {
|
||||
if (cardRef.current)
|
||||
{
|
||||
cardRef.current.style.zIndex = ''
|
||||
cardRef.current.style.position = ''
|
||||
}
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
loading={i < 12 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="object-cover w-full h-full"/>
|
||||
</motion.div>
|
||||
</PrefetchLink>)
|
||||
})}
|
||||
</div>)
|
||||
return (
|
||||
<PrefetchLink
|
||||
to={`/posts/${ post.id }`}
|
||||
key={post.id}
|
||||
className="w-40 h-40"
|
||||
state={{ sharedId: `page-${ post.id }` }}
|
||||
onClick={e => {
|
||||
const sharedId = `page-${ post.id }`
|
||||
setForLocationKey (location.key, sharedId)
|
||||
onClick?.(e)
|
||||
}}>
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
layoutId={layoutId}
|
||||
className="w-full h-full overflow-hidden rounded-xl shadow
|
||||
transform-gpu will-change-transform"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onLayoutAnimationStart={() => {
|
||||
if (cardRef.current)
|
||||
{
|
||||
cardRef.current.style.position = 'relative'
|
||||
cardRef.current.style.zIndex = '9999'
|
||||
}
|
||||
}}
|
||||
onLayoutAnimationComplete={() => {
|
||||
if (cardRef.current)
|
||||
{
|
||||
cardRef.current.style.zIndex = ''
|
||||
cardRef.current.style.position = ''
|
||||
}
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
loading={i < 12 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="object-cover w-full h-full"/>
|
||||
</motion.div>
|
||||
</PrefetchLink>)
|
||||
})}
|
||||
</div>
|
||||
</>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createPath, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
@@ -11,6 +12,7 @@ import type { To } from 'react-router-dom'
|
||||
|
||||
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
to: To
|
||||
state?: Record<string, string>
|
||||
replace?: boolean
|
||||
className?: string
|
||||
cancelOnError?: boolean }
|
||||
@@ -20,11 +22,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
to,
|
||||
replace,
|
||||
className,
|
||||
state,
|
||||
onMouseEnter,
|
||||
onTouchStart,
|
||||
onClick,
|
||||
cancelOnError = false,
|
||||
...rest }, ref) => {
|
||||
if ('onClick' in rest)
|
||||
delete rest['onClick']
|
||||
|
||||
const navigate = useNavigate ()
|
||||
const qc = useQueryClient ()
|
||||
const url = useMemo (() => {
|
||||
@@ -57,25 +63,37 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
}
|
||||
|
||||
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(ev)
|
||||
try
|
||||
{
|
||||
onClick?.(ev)
|
||||
|
||||
if (ev.defaultPrevented
|
||||
|| ev.metaKey
|
||||
|| ev.ctrlKey
|
||||
|| ev.shiftKey
|
||||
|| ev.altKey)
|
||||
return
|
||||
if (ev.defaultPrevented
|
||||
|| ev.metaKey
|
||||
|| ev.ctrlKey
|
||||
|| ev.shiftKey
|
||||
|| ev.altKey)
|
||||
return
|
||||
|
||||
ev.preventDefault ()
|
||||
ev.preventDefault ()
|
||||
|
||||
setOverlay (true)
|
||||
const ok = await doPrefetch ()
|
||||
setOverlay (false)
|
||||
flushSync (() => {
|
||||
setOverlay (true)
|
||||
})
|
||||
const ok = await doPrefetch ()
|
||||
flushSync (() => {
|
||||
setOverlay (false)
|
||||
})
|
||||
|
||||
if (!(ok) && cancelOnError)
|
||||
return
|
||||
if (!(ok) && cancelOnError)
|
||||
return
|
||||
|
||||
navigate (to, { replace })
|
||||
navigate (to, { replace, ...(state && { state }) })
|
||||
}
|
||||
catch (ex)
|
||||
{
|
||||
console.log (ex)
|
||||
ev.preventDefault ()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
|
||||
import PostEditForm from '@/components/PostEditForm'
|
||||
import PostEmbed from '@/components/PostEmbed'
|
||||
@@ -31,6 +31,11 @@ export default (({ user }: Props) => {
|
||||
const postId = String (id ?? '')
|
||||
const postKey = postsKeys.show (postId)
|
||||
|
||||
const location = useLocation ()
|
||||
const navState = (location.state ?? { }) as { sharedId?: string }
|
||||
|
||||
const inboundSharedId = navState.sharedId
|
||||
|
||||
const { data: post, isError: errorFlg, error } = useQuery ({
|
||||
enabled: Boolean (id),
|
||||
queryKey: postKey,
|
||||
@@ -89,62 +94,65 @@ export default (({ user }: Props) => {
|
||||
: 'bg-gray-500 hover:bg-gray-600')
|
||||
|
||||
return (
|
||||
<div className="md:flex md:flex-1">
|
||||
<Helmet>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
<>
|
||||
<div className="md:flex md:flex-1">
|
||||
<Helmet>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<TagDetailSidebar post={post ?? null}/>
|
||||
<div className="hidden md:block">
|
||||
<TagDetailSidebar post={post ?? null}/>
|
||||
</div>
|
||||
|
||||
<MainArea className="relative">
|
||||
{post
|
||||
? (
|
||||
<>
|
||||
{inboundSharedId === `page-${ id }` && (
|
||||
<motion.div
|
||||
layoutId={inboundSharedId}
|
||||
className="absolute top-4 left-4 w-[640px] max-w-full h-[360px]
|
||||
overflow-hidden rounded-xl shadow pointer-events-none
|
||||
opacity-0 z-10">
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
className="object-cover w-full h-full"/>
|
||||
</motion.div>)}
|
||||
|
||||
<PostEmbed post={post}/>
|
||||
<Button onClick={() => changeViewedFlg.mutate ()}
|
||||
disabled={changeViewedFlg.isPending}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
{post.viewed ? '閲覧済' : '未閲覧'}
|
||||
</Button>
|
||||
<TabGroup>
|
||||
<Tab name="関聯">
|
||||
{post.related.length > 0
|
||||
? <PostList posts={post.related}/>
|
||||
: 'まだないよ(笑)'}
|
||||
</Tab>
|
||||
{['admin', 'member'].some (r => user?.role === r) && (
|
||||
<Tab name="編輯">
|
||||
<PostEditForm
|
||||
post={post}
|
||||
onSave={newPost => {
|
||||
qc.setQueryData (postsKeys.show (postId),
|
||||
(prev: any) => newPost ?? prev)
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
toast ({ description: '更新しました.' })
|
||||
}}/>
|
||||
</Tab>)}
|
||||
</TabGroup>
|
||||
</>)
|
||||
: 'Loading...'}
|
||||
</MainArea>
|
||||
|
||||
<div className="md:hidden">
|
||||
<TagDetailSidebar post={post ?? null}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MainArea className="relative">
|
||||
<motion.div
|
||||
layoutId={`page-${ String (id) }`}
|
||||
initial={{ clipPath: 'inset(0% 0% 0% 0%)' }}
|
||||
animate={{ clipPath: 'inset(0% 0% 0% 0% round 0px)', opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}
|
||||
className="absolute overflow-hidden transform-gpu will-change-transform
|
||||
inset-0 pointer-events-none z-10 w-[640px] h-[360px]">
|
||||
<img src={post?.thumbnailBase || post?.thumbnail}
|
||||
alt={post?.url}/>
|
||||
</motion.div>
|
||||
|
||||
{post
|
||||
? (
|
||||
<>
|
||||
<PostEmbed post={post}/>
|
||||
<Button onClick={() => changeViewedFlg.mutate ()}
|
||||
disabled={changeViewedFlg.isPending}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
{post.viewed ? '閲覧済' : '未閲覧'}
|
||||
</Button>
|
||||
<TabGroup>
|
||||
<Tab name="関聯">
|
||||
{post.related.length > 0
|
||||
? <PostList posts={post.related}/>
|
||||
: 'まだないよ(笑)'}
|
||||
</Tab>
|
||||
{['admin', 'member'].some (r => user?.role === r) && (
|
||||
<Tab name="編輯">
|
||||
<PostEditForm
|
||||
post={post}
|
||||
onSave={newPost => {
|
||||
qc.setQueryData (postsKeys.show (postId),
|
||||
(prev: any) => newPost ?? prev)
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
toast ({ description: '更新しました.' })
|
||||
}}/>
|
||||
</Tab>)}
|
||||
</TabGroup>
|
||||
</>)
|
||||
: 'Loading...'}
|
||||
</MainArea>
|
||||
|
||||
<div className="md:hidden">
|
||||
<TagDetailSidebar post={post ?? null}/>
|
||||
</div>
|
||||
</div>)
|
||||
</>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SharedTransitionState = {
|
||||
byLocationKey: Record<string, string>
|
||||
setForLocationKey: (locationKey: string, sharedId: string) => void
|
||||
clearForLocationKey: (locationKey: string) => void }
|
||||
|
||||
|
||||
export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
|
||||
byLocationKey: { },
|
||||
setForLocationKey: (locationKey, sharedId) =>
|
||||
set (state => ({ byLocationKey: { ...state.byLocationKey,
|
||||
[locationKey]: sharedId } })),
|
||||
clearForLocationKey: (locationKey) =>
|
||||
set (state => {
|
||||
const next = { ...state.byLocationKey }
|
||||
delete next[locationKey]
|
||||
return { byLocationKey: next }
|
||||
}) }))
|
||||
新しい課題から参照
ユーザをブロックする