このコミットが含まれているのは:
2026-02-01 06:34:40 +09:00
コミット 109b57bb56
4個のファイルの変更171行の追加138行の削除
+55 -67
ファイルの表示
@@ -1,10 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useRef } from 'react' 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 PrefetchLink from '@/components/PrefetchLink'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -15,73 +14,62 @@ type Props = { posts: Post[]
export default (({ posts, onClick }: Props) => { 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 ({ const cardRef = useRef<HTMLDivElement> (null)
queryKey: ['post', id],
queryFn: () => fetchPost (id) }) const outboundSharedId = useSharedTransitionStore (s => s.byLocationKey[location.key])
return ( return (
<div className="flex flex-wrap gap-6 p-4"> <>
{posts.map ((post, i) => { <div className="flex flex-wrap gap-6 p-4">
const id = String (post.id) {posts.map ((post, i) => {
const hRef = `/posts/${ id }` const id2 = `page-${ post.id }`
const cardRef = useRef<HTMLDivElement> (null) const layoutId = outboundSharedId === id2 ? id2 : undefined
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { return (
onClick?.(ev) <PrefetchLink
to={`/posts/${ post.id }`}
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1) key={post.id}
return className="w-40 h-40"
state={{ sharedId: `page-${ post.id }` }}
ev.preventDefault () onClick={e => {
const sharedId = `page-${ post.id }`
await qc.ensureQueryData ({ setForLocationKey (location.key, sharedId)
queryKey: ['post', id], onClick?.(e)
queryFn: () => fetchPost (id) }) }}>
<motion.div
navigate (hRef) ref={cardRef}
} layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
return ( transform-gpu will-change-transform"
<PrefetchLink whileHover={{ scale: 1.02 }}
to={hRef} onLayoutAnimationStart={() => {
key={id} if (cardRef.current)
className="w-40 h-40" {
onMouseEnter={() => prefetch (id)} cardRef.current.style.position = 'relative'
onFocus={() => prefetch (id)} cardRef.current.style.zIndex = '9999'
onClick={handleClick}> }
<motion.div }}
ref={cardRef} onLayoutAnimationComplete={() => {
layoutId={`page-${ id }`} if (cardRef.current)
className="w-full h-full overflow-hidden rounded-xl shadow {
transform-gpu will-change-transform" cardRef.current.style.zIndex = ''
whileHover={{ scale: 1.02 }} cardRef.current.style.position = ''
onLayoutAnimationStart={() => { }
if (cardRef.current) }}
{ transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
cardRef.current.style.position = 'relative' <img src={post.thumbnail || post.thumbnailBase || undefined}
cardRef.current.style.zIndex = '9999' alt={post.title || post.url}
} title={post.title || post.url || undefined}
}} loading={i < 12 ? 'eager' : 'lazy'}
onLayoutAnimationComplete={() => { decoding="async"
if (cardRef.current) className="object-cover w-full h-full"/>
{ </motion.div>
cardRef.current.style.zIndex = '' </PrefetchLink>)
cardRef.current.style.position = '' })}
} </div>
}} </>)
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> }) satisfies FC<Props>
+32 -14
ファイルの表示
@@ -1,5 +1,6 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useNavigate } from 'react-router-dom' import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
@@ -11,6 +12,7 @@ import type { To } from 'react-router-dom'
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: To to: To
state?: Record<string, string>
replace?: boolean replace?: boolean
className?: string className?: string
cancelOnError?: boolean } cancelOnError?: boolean }
@@ -20,11 +22,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
to, to,
replace, replace,
className, className,
state,
onMouseEnter, onMouseEnter,
onTouchStart, onTouchStart,
onClick, onClick,
cancelOnError = false, cancelOnError = false,
...rest }, ref) => { ...rest }, ref) => {
if ('onClick' in rest)
delete rest['onClick']
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const url = useMemo (() => { const url = useMemo (() => {
@@ -57,25 +63,37 @@ export default forwardRef<HTMLAnchorElement, Props> (({
} }
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => {
onClick?.(ev) try
{
onClick?.(ev)
if (ev.defaultPrevented if (ev.defaultPrevented
|| ev.metaKey || ev.metaKey
|| ev.ctrlKey || ev.ctrlKey
|| ev.shiftKey || ev.shiftKey
|| ev.altKey) || ev.altKey)
return return
ev.preventDefault () ev.preventDefault ()
setOverlay (true) flushSync (() => {
const ok = await doPrefetch () setOverlay (true)
setOverlay (false) })
const ok = await doPrefetch ()
flushSync (() => {
setOverlay (false)
})
if (!(ok) && cancelOnError) if (!(ok) && cancelOnError)
return return
navigate (to, { replace }) navigate (to, { replace, ...(state && { state }) })
}
catch (ex)
{
console.log (ex)
ev.preventDefault ()
}
} }
return ( return (
+65 -57
ファイルの表示
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' 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 PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
@@ -31,6 +31,11 @@ export default (({ user }: Props) => {
const postId = String (id ?? '') const postId = String (id ?? '')
const postKey = postsKeys.show (postId) 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 ({ const { data: post, isError: errorFlg, error } = useQuery ({
enabled: Boolean (id), enabled: Boolean (id),
queryKey: postKey, queryKey: postKey,
@@ -89,62 +94,65 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600') : 'bg-gray-500 hover:bg-gray-600')
return ( return (
<div className="md:flex md:flex-1"> <>
<Helmet> <div className="md:flex md:flex-1">
{(post?.thumbnail || post?.thumbnailBase) && ( <Helmet>
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} {(post?.thumbnail || post?.thumbnailBase) && (
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
</Helmet> {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>
<div className="hidden md:block"> <div className="hidden md:block">
<TagDetailSidebar post={post ?? null}/> <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> </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> }) satisfies FC<Props>
+19
ファイルの表示
@@ -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 }
}) }))