feat: アニメーションの一部実装(#176) (#178)

#176 完了

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #178
This commit was merged in pull request #178.
This commit is contained in:
2025-12-14 16:24:04 +09:00
parent f36837f0d8
commit 92d9fe7733
5 changed files with 204 additions and 120 deletions
+43
View File
@@ -17,6 +17,7 @@
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
@@ -3573,6 +3574,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5216,6 +5244,21 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+1
View File
@@ -19,6 +19,7 @@
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
+67 -56
View File
@@ -1,3 +1,4 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
@@ -18,18 +19,23 @@ const renderTagTree = (
tag: Tag, tag: Tag,
nestLevel: number, nestLevel: number,
path: string, path: string,
): ReactNode[] => { ): ReactNode[] => {
const key = `${ path }-${ tag.id }` const key = `${ path }-${ tag.id }`
const self = ( const self = (
<li key={key} className="mb-1"> <motion.li
key={key}
layout
transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1">
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</li>) </motion.li>)
return [self, return [self,
...(tag.children ...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1) ?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] .flatMap (child => renderTagTree (child, nestLevel + 1, key)))
?? [])]
} }
@@ -70,55 +76,60 @@ export default (({ post }: Props) => {
return ( return (
<SidebarComponent> <SidebarComponent>
<TagSearch/> <TagSearch/>
{CATEGORIES.map ((cat: Category) => cat in tags && ( <motion.div layout>
<div className="my-3" key={cat}> {CATEGORIES.map ((cat: Category) => cat in tags && (
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> <motion.div layout className="my-3" key={cat}>
<ul> <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
</ul> <motion.ul layout>
</div>))} <AnimatePresence initial={false}>
{post && ( {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
<div> </AnimatePresence>
<SectionTitle></SectionTitle> </motion.ul>
<ul> </motion.div>))}
<li>Id.: {post.id}</li> {post && (
{/* TODO: uploadedUser の取得を対応したらコメント外す */} <div>
{/* <SectionTitle></SectionTitle>
<li> <ul>
<>耕作者: </> <li>Id.: {post.id}</li>
{post.uploadedUser {/* TODO: uploadedUser の取得を対応したらコメント外す */}
? ( {/*
<Link to={`/users/${ post.uploadedUser.id }`}> <li>
{post.uploadedUser.name || '名もなきニジラー'} <>耕作者: </>
</Link>) {post.uploadedUser
: 'bot操作'} ? (
</li> <Link to={`/users/${ post.uploadedUser.id }`}>
*/} {post.uploadedUser.name || '名もなきニジラー'}
<li>: {(new Date (post.createdAt)).toLocaleString ()}</li> </Link>)
<li> : 'bot操作'}
<>: </> </li>
<a */}
className="break-all" <li>: {(new Date (post.createdAt)).toLocaleString ()}</li>
href={post.url} <li>
target="_blank" <>: </>
rel="noopener noreferrer nofollow"> <a
{post.url} className="break-all"
</a> href={post.url}
</li> target="_blank"
<li> rel="noopener noreferrer nofollow">
{/* TODO: 表示形式きしょすぎるので何とかする */} {post.url}
<>稿: </> </a>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore) </li>
? '不明' <li>
: ( {/* TODO: 表示形式きしょすぎるので何とかする */}
<> <>稿: </>
{post.originalCreatedFrom {!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} ? '不明'
{post.originalCreatedBefore : (
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} <>
</>)} {post.originalCreatedFrom
</li> && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
</ul> {post.originalCreatedBefore
</div>)} && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
</li>
</ul>
</div>)}
</motion.div>
</SidebarComponent>) </SidebarComponent>)
}) satisfies FC<Props> }) satisfies FC<Props>
+47 -32
View File
@@ -1,4 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -61,37 +61,52 @@ export default (({ posts }: Props) => {
return ( return (
<SidebarComponent> <SidebarComponent>
<TagSearch/> <TagSearch/>
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
<SectionTitle></SectionTitle> <AnimatePresence initial={false}>
<ul> {tagsVsbl && (
{CATEGORIES.flatMap (cat => cat in tags ? ( <motion.div
tags[cat].map (tag => ( key="sptags"
<li key={tag.id} className="mb-1"> className="md:block mt-4"
<TagLink tag={tag}/> variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
</li>))) : [])} height: 0 },
</ul> visible: { clipPath: 'inset(0 0 0% 0)',
<SectionTitle></SectionTitle> height: 'auto'} }}
{posts.length > 0 && ( initial="hidden"
<a href="#" animate="visible"
onClick={ev => { exit="hidden"
ev.preventDefault () transition={{ duration: .2, ease: 'easeOut' }}>
void ((async () => { <SectionTitle></SectionTitle>
try <ul>
{ {CATEGORIES.flatMap (cat => cat in tags ? (
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, tags[cat].map (tag => (
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), <li key={tag.id} className="mb-1">
match: (anyFlg ? 'any' : 'all') } }) <TagLink tag={tag}/>
navigate (`/posts/${ (data as Post).id }`) </li>))) : [])}
} </ul>
catch <SectionTitle></SectionTitle>
{ {posts.length > 0 && (
; <a href="#"
} onClick={ev => {
}) ()) ev.preventDefault ()
}}> void ((async () => {
try
</a>)} {
</div> const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`)
}
catch
{
;
}
}) ())
}}>
</a>)}
</motion.div>)}
</AnimatePresence>
<a href="#" <a href="#"
className="md:hidden block my-2 text-center text-sm className="md:hidden block my-2 text-center text-sm
text-gray-500 hover:text-gray-400 text-gray-500 hover:text-gray-400
+46 -32
View File
@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys' import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion'
import { Fragment, useState, useEffect } from 'react' import { Fragment, useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
@@ -136,37 +137,50 @@ export default (({ user }: Props) => {
</Link>))} </Link>))}
</div> </div>
<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden', <AnimatePresence initial={false}>
'bg-yellow-200 dark:bg-red-975 items-start')}> {menuOpen && (
<Separator/> <motion.div
{menu.map ((item, i) => ( key="spmenu"
<Fragment key={i}> className={cn ('flex flex-col md:hidden',
<Link to={i === openItemIdx ? item.to : '#'} 'bg-yellow-200 dark:bg-red-975 items-start')}
className={cn ('w-full min-h-[40px] flex items-center pl-8', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
((i === openItemIdx) height: 0 },
&& 'font-bold bg-yellow-50 dark:bg-red-950'))} open: { clipPath: 'inset(0 0 0% 0)',
onClick={ev => { height: 'auto' } }}
if (i !== openItemIdx) initial="closed"
{ animate="open"
ev.preventDefault () exit="closed"
setOpenItemIdx (i) transition={{ duration: .2, ease: 'easeOut' }}>
} <Separator/>
}}> {menu.map ((item, i) => (
{item.name} <Fragment key={i}>
</Link> <Link to={i === openItemIdx ? item.to : '#'}
{i === openItemIdx && ( className={cn ('w-full min-h-[40px] flex items-center pl-8',
item.subMenu ((i === openItemIdx)
.filter (subItem => subItem.visible ?? true) && 'font-bold bg-yellow-50 dark:bg-red-950'))}
.map ((subItem, j) => 'component' in subItem ? subItem.component : ( onClick={ev => {
<Link key={j} if (i !== openItemIdx)
to={subItem.to} {
className="w-full min-h-[36px] flex items-center pl-12 ev.preventDefault ()
bg-yellow-50 dark:bg-red-950"> setOpenItemIdx (i)
{subItem.name} }
</Link>)))} }}>
</Fragment>))} {item.name}
<TopNavUser user={user} sp/> </Link>
<Separator/> {i === openItemIdx && (
</div> item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => 'component' in subItem ? subItem.component : (
<Link key={j}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12
bg-yellow-50 dark:bg-red-950">
{subItem.name}
</Link>)))}
</Fragment>))}
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
</>) </>)
}) satisfies FC<Props> }) satisfies FC<Props>