アニメーション(#139) (#252)

#139

#139

#139

#139

#139

Merge branch 'feature/140' into feature/139

Merge remote-tracking branch 'origin/main' into feature/139

#140

Merge remote-tracking branch 'origin/main' into feature/140

Merge remote-tracking branch 'origin/main' into feature/140

#140 ぼちぼち

Merge remote-tracking branch 'origin/main' into feature/140

#140

#140

#140

#139 アニメーション

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #252
This commit was merged in pull request #252.
This commit is contained in:
2026-02-05 23:25:27 +09:00
parent f3cd108b2e
commit 797e67ac37
22 changed files with 810 additions and 311 deletions
+64 -16
View File
@@ -1,4 +1,9 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
import type { FC, MouseEvent } from 'react'
@@ -8,18 +13,61 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }
export default (({ posts, onClick }: Props) => (
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => (
<Link to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
onClick={onClick}>
<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"/>
</Link>))}
</div>)) satisfies FC<Props>
export default (({ posts, onClick }: Props) => {
const location = useLocation ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
const cardRef = useRef<HTMLDivElement> (null)
return (
<>
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const id2 = `page-${ post.id }`
const layoutId = id2
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>
+107
View File
@@ -0,0 +1,107 @@
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'
import { prefetchForURL } from '@/lib/prefetchers'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
import type { To } from 'react-router-dom'
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: To
state?: Record<string, string>
replace?: boolean
className?: string
cancelOnError?: boolean }
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 (() => {
const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, location.origin)).toString ()
}, [to])
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
try
{
await prefetchForURL (qc, url)
return true
}
catch (e)
{
console.error ('データ取得エラー', e)
return false
}
}
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev)
await doPrefetch ()
}
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev)
await doPrefetch ()
}
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => {
try
{
onClick?.(ev)
if (ev.defaultPrevented
|| ev.metaKey
|| ev.ctrlKey
|| ev.shiftKey
|| ev.altKey)
return
ev.preventDefault ()
flushSync (() => {
setOverlay (true)
})
const ok = await doPrefetch ()
flushSync (() => {
setOverlay (false)
})
if (!(ok) && cancelOnError)
return
navigate (to, { replace, ...(state && { state }) })
}
catch (ex)
{
console.log (ex)
ev.preventDefault ()
}
}
return (
<a ref={ref}
href={typeof to === 'string' ? to : createPath (to)}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onClick={handleClick}
className={cn ('cursor-pointer', className)}
{...rest}/>)
})
@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type { FC } from 'react'
type OverlayStore = {
active: boolean
setActive: (v: boolean) => void }
export const useOverlayStore = create<OverlayStore> (set => ({
active: false,
setActive: v => set ({ active: v }) }))
export default (() => {
const active = useOverlayStore (s => s.active)
useEffect (() => {
if (active)
{
document.body.style.overflow = 'hidden'
document.body.setAttribute ('aria-busy', 'true')
}
else
{
document.body.style.overflow = ''
document.body.removeAttribute ('aria-busy')
}
}, [active])
if (!(active))
return null
return (
<div
role="progressbar"
aria-label="Loading"
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
Loading...
</div>
</div>
</div>)
}) satisfies FC
+15 -4
View File
@@ -2,6 +2,7 @@ import axios from 'axios'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { API_BASE_URL } from '@/config'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils'
@@ -13,7 +14,8 @@ import type { Tag } from '@/types'
type CommonProps = { tag: Tag
nestLevel?: number
withWiki?: boolean
withCount?: boolean }
withCount?: boolean
prefetch?: boolean }
type PropsWithLink =
CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>>
@@ -29,6 +31,7 @@ export default (({ tag,
linkFlg = true,
withWiki = true,
withCount = true,
prefetch = false,
...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)
@@ -100,11 +103,19 @@ export default (({ tag,
</>)}
{linkFlg
? (
<Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</Link>)
{tag.name}
</PrefetchLink>
: <Link
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</Link>)
: (
<span className={spanClass}
{...props}>
+5 -4
View File
@@ -10,16 +10,17 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts'
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { Post, Tag } from '@/types'
type TagByCategory = Record<string, Tag[]>
type Props = { posts: Post[] }
type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }
export default (({ posts }: Props) => {
export default (({ posts, onClick }: Props) => {
const navigate = useNavigate ()
const [tagsVsbl, setTagsVsbl] = useState (false)
@@ -65,7 +66,7 @@ export default (({ posts }: Props) => {
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<TagLink tag={tag}/>
<TagLink tag={tag} prefetch onClick={onClick}/>
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
+48 -43
View File
@@ -1,18 +1,18 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { API_BASE_URL } from '@/config'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { Menu, Tag, User, WikiPage } from '@/types'
import type { Menu, User } from '@/types'
type Props = { user: User | null }
@@ -120,11 +120,8 @@ export default (({ user }: Props) => {
const fetchPostCount = async () => {
try
{
const pageRes = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`)
const wikiPage = toCamel (pageRes.data as any, { deep: true }) as WikiPage
const tagRes = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`)
const tag = toCamel (tagRes.data as any, { deep: true }) as Tag
const wikiPage = await fetchWikiPage (String (wikiId ?? ''))
const tag = await fetchTagByName (wikiPage.title)
setPostCount (tag.postCount)
}
@@ -141,11 +138,15 @@ export default (({ user }: Props) => {
<nav className="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-full">
<Link to="/"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100">
<PrefetchLink
to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={() => {
scroll (0, 0)
}}>
</Link>
</PrefetchLink>
<div ref={navRef} className="relative hidden md:flex h-full items-center">
<div aria-hidden
@@ -157,15 +158,16 @@ export default (({ user }: Props) => {
opacity: hl.visible ? 1 : 0 }}/>
{menu.map ((item, i) => (
<Link key={i}
to={item.to}
ref={el => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
<PrefetchLink
key={i}
to={item.to}
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</Link>))}
</PrefetchLink>))}
</div>
</div>
@@ -203,11 +205,12 @@ export default (({ user }: Props) => {
'component' in item
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
: (
<Link key={`l-${ i }`}
to={item.to}
className="h-full flex items-center px-3">
<PrefetchLink
key={`l-${ i }`}
to={item.to}
className="h-full flex items-center px-3">
{item.name}
</Link>)))}
</PrefetchLink>)))}
</motion.div>
</AnimatePresence>
</div>
@@ -229,19 +232,20 @@ export default (({ user }: Props) => {
<Separator/>
{menu.map ((item, i) => (
<Fragment key={i}>
<Link to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={ev => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</Link>
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
@@ -267,11 +271,12 @@ export default (({ user }: Props) => {
{subItem.component}
</Fragment>)
: (
<Link key={`sp-l-${ i }-${ j }`}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12">
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</Link>)))}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
@@ -1,4 +1,6 @@
import { Link, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import type { FC } from 'react'
@@ -61,7 +63,7 @@ export default (({ page, totalPages, siblingCount = 4 }) => {
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<div className="flex items-center gap-2">
{(page > 1)
? <Link to={buildTo (page - 1)} aria-label="前のページ">&lt;</Link>
? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ">&lt;</PrefetchLink>
: <span aria-hidden>&lt;</span>}
{pages.map ((p, idx) => (
@@ -69,10 +71,10 @@ export default (({ page, totalPages, siblingCount = 4 }) => {
? <span key={`dots-${ idx }`}></span>
: ((p === page)
? <span key={p} className="font-bold" aria-current="page">{p}</span>
: <Link key={p} to={buildTo (p)}>{p}</Link>)))}
: <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))}
{(page < totalPages)
? <Link to={buildTo (page + 1)} aria-label="次のページ">&gt;</Link>
? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">&gt;</PrefetchLink>
: <span aria-hidden>&gt;</span>}
</div>
</nav>)
+9 -5
View File
@@ -1,9 +1,13 @@
import React from 'react'
import { cn } from '@/lib/utils'
type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string }
export default ({ children }: Props) => (
<main className="flex-1 overflow-y-auto p-4">
export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
{children}
</main>)
</main>)) satisfies FC<Props>