| @@ -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 ({ | |||||
| queryKey: ['post', id], | |||||
| queryFn: () => fetchPost (id) }) | |||||
| const cardRef = useRef<HTMLDivElement> (null) | |||||
| 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) | |||||
| const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | |||||
| onClick?.(ev) | |||||
| if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1) | |||||
| return | |||||
| const outboundSharedId = useSharedTransitionStore (s => s.byLocationKey[location.key]) | |||||
| ev.preventDefault () | |||||
| await qc.ensureQueryData ({ | |||||
| queryKey: ['post', id], | |||||
| queryFn: () => fetchPost (id) }) | |||||
| navigate (hRef) | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| <div className="flex flex-wrap gap-6 p-4"> | |||||
| {posts.map ((post, i) => { | |||||
| const id2 = `page-${ post.id }` | |||||
| const layoutId = outboundSharedId === id2 ? id2 : undefined | |||||
| 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> | }) 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 | |||||
| || 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 ( | 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> | |||||
| {(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> | |||||
| <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 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> | |||||
| <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> | ||||
| </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 } | |||||
| }) })) | |||||