このコミットが含まれているのは:
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}) }))
|
||||||
新しい課題から参照
ユーザをブロックする