| @@ -17,6 +17,7 @@ | |||
| "camelcase-keys": "^9.1.3", | |||
| "class-variance-authority": "^0.7.1", | |||
| "clsx": "^2.1.1", | |||
| "framer-motion": "^12.23.26", | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| @@ -3573,6 +3574,33 @@ | |||
| "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": { | |||
| "version": "2.3.3", | |||
| "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |||
| @@ -5216,6 +5244,21 @@ | |||
| "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": { | |||
| "version": "2.1.3", | |||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |||
| @@ -19,6 +19,7 @@ | |||
| "camelcase-keys": "^9.1.3", | |||
| "class-variance-authority": "^0.7.1", | |||
| "clsx": "^2.1.1", | |||
| "framer-motion": "^12.23.26", | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| @@ -1,3 +1,4 @@ | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { useEffect, useState } from 'react' | |||
| import TagLink from '@/components/TagLink' | |||
| @@ -18,18 +19,23 @@ const renderTagTree = ( | |||
| tag: Tag, | |||
| nestLevel: number, | |||
| path: string, | |||
| ): ReactNode[] => { | |||
| ): ReactNode[] => { | |||
| const key = `${ path }-${ tag.id }` | |||
| 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}/> | |||
| </li>) | |||
| </motion.li>) | |||
| return [self, | |||
| ...(tag.children | |||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | |||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] | |||
| ...((tag.children | |||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | |||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key))) | |||
| ?? [])] | |||
| } | |||
| @@ -70,55 +76,60 @@ export default (({ post }: Props) => { | |||
| return ( | |||
| <SidebarComponent> | |||
| <TagSearch/> | |||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||
| <div className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <ul> | |||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||
| </ul> | |||
| </div>))} | |||
| {post && ( | |||
| <div> | |||
| <SectionTitle>情報</SectionTitle> | |||
| <ul> | |||
| <li>Id.: {post.id}</li> | |||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||
| {/* | |||
| <li> | |||
| <>耕作者: </> | |||
| {post.uploadedUser | |||
| ? ( | |||
| <Link to={`/users/${ post.uploadedUser.id }`}> | |||
| {post.uploadedUser.name || '名もなきニジラー'} | |||
| </Link>) | |||
| : 'bot操作'} | |||
| </li> | |||
| */} | |||
| <li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| className="break-all" | |||
| href={post.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {post.url} | |||
| </a> | |||
| </li> | |||
| <li> | |||
| {/* TODO: 表示形式きしょすぎるので何とかする */} | |||
| <>オリジナルの投稿日時: </> | |||
| {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) | |||
| ? '不明' | |||
| : ( | |||
| <> | |||
| {post.originalCreatedFrom | |||
| && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} | |||
| {post.originalCreatedBefore | |||
| && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} | |||
| </>)} | |||
| </li> | |||
| </ul> | |||
| </div>)} | |||
| <motion.div layout> | |||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||
| <motion.div layout className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <motion.ul layout> | |||
| <AnimatePresence initial={false}> | |||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||
| </AnimatePresence> | |||
| </motion.ul> | |||
| </motion.div>))} | |||
| {post && ( | |||
| <div> | |||
| <SectionTitle>情報</SectionTitle> | |||
| <ul> | |||
| <li>Id.: {post.id}</li> | |||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||
| {/* | |||
| <li> | |||
| <>耕作者: </> | |||
| {post.uploadedUser | |||
| ? ( | |||
| <Link to={`/users/${ post.uploadedUser.id }`}> | |||
| {post.uploadedUser.name || '名もなきニジラー'} | |||
| </Link>) | |||
| : 'bot操作'} | |||
| </li> | |||
| */} | |||
| <li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| className="break-all" | |||
| href={post.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {post.url} | |||
| </a> | |||
| </li> | |||
| <li> | |||
| {/* TODO: 表示形式きしょすぎるので何とかする */} | |||
| <>オリジナルの投稿日時: </> | |||
| {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) | |||
| ? '不明' | |||
| : ( | |||
| <> | |||
| {post.originalCreatedFrom | |||
| && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} | |||
| {post.originalCreatedBefore | |||
| && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} | |||
| </>)} | |||
| </li> | |||
| </ul> | |||
| </div>)} | |||
| </motion.div> | |||
| </SidebarComponent>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,4 +1,5 @@ | |||
| import axios from 'axios' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { useEffect, useState } from 'react' | |||
| import { useLocation, useNavigate } from 'react-router-dom' | |||
| @@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { CATEGORIES } from '@/consts' | |||
| import { cn } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| @@ -61,37 +61,52 @@ export default (({ posts }: Props) => { | |||
| return ( | |||
| <SidebarComponent> | |||
| <TagSearch/> | |||
| <div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}> | |||
| <SectionTitle>タグ</SectionTitle> | |||
| <ul> | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag}/> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| {posts.length > 0 && ( | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| 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>)} | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| {tagsVsbl && ( | |||
| <motion.div | |||
| key="sptags" | |||
| className="md:block mt-4" | |||
| variants={{ hidden: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0 }, | |||
| visible: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto'} }} | |||
| initial="hidden" | |||
| animate="visible" | |||
| exit="hidden" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| <SectionTitle>タグ</SectionTitle> | |||
| <ul> | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag}/> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| {posts.length > 0 && ( | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| 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="#" | |||
| className="md:hidden block my-2 text-center text-sm | |||
| text-gray-500 hover:text-gray-400 | |||
| @@ -1,5 +1,6 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { Fragment, useState, useEffect } from 'react' | |||
| import { Link, useLocation } from 'react-router-dom' | |||
| @@ -136,37 +137,50 @@ export default (({ user }: Props) => { | |||
| </Link>))} | |||
| </div> | |||
| <div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden', | |||
| 'bg-yellow-200 dark:bg-red-975 items-start')}> | |||
| <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) | |||
| } | |||
| }}> | |||
| {item.name} | |||
| </Link> | |||
| {i === openItemIdx && ( | |||
| 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/> | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| {menuOpen && ( | |||
| <motion.div | |||
| key="spmenu" | |||
| className={cn ('flex flex-col md:hidden', | |||
| 'bg-yellow-200 dark:bg-red-975 items-start')} | |||
| variants={{ closed: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0 }, | |||
| open: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto' } }} | |||
| initial="closed" | |||
| animate="open" | |||
| exit="closed" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| <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) | |||
| } | |||
| }}> | |||
| {item.name} | |||
| </Link> | |||
| {i === openItemIdx && ( | |||
| 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> | |||