diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bb2284b..4ad6239 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", + "@tanstack/react-query": "^5.90.2", "axios": "^1.10.0", "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", @@ -24,16 +25,18 @@ "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", + "path-to-regexp": "^8.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-helmet-async": "^2.0.5", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", - "react-router-dom": "^6.30.0", + "react-router-dom": "^6.30.1", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", - "unist-util-visit-parents": "^6.0.1" + "unist-util-visit-parents": "^6.0.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -1966,6 +1969,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/axios": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", @@ -5552,6 +5581,16 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5991,9 +6030,9 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0" @@ -6006,13 +6045,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "react-router": "6.30.1" }, "engines": { "node": ">=14.0.0" @@ -7286,6 +7325,35 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 57a13d9..df73a58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", + "@tanstack/react-query": "^5.90.2", "axios": "^1.10.0", "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", @@ -26,15 +27,17 @@ "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", + "path-to-regexp": "^8.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-helmet-async": "^2.0.5", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", - "react-router-dom": "^6.30.0", + "react-router-dom": "^6.30.1", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", + "zustand": "^5.0.8", "unist-util-visit-parents": "^6.0.1" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2195ca8..7e950f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,14 @@ import axios from 'axios' import toCamel from 'camelcase-keys' +import { AnimatePresence, LayoutGroup } from 'framer-motion' import { useEffect, useState } from 'react' -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, + Navigate, + Route, + Routes, + useLocation } from 'react-router-dom' +import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { API_BASE_URL } from '@/config' @@ -21,11 +27,48 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' -import type { FC } from 'react' +import type { Dispatch, FC, SetStateAction } from 'react' import type { User } from '@/types' +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + const location = useLocation () + + return ( + + + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + ) +} + + +const PostDetailRoute = ({ user }: { user: User | null }) => { + const location = useLocation () + const key = location.pathname + return +} + + export default (() => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) @@ -72,27 +115,14 @@ export default (() => { } return ( - -
- - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - -
- -
) + <> + + +
+ + +
+ +
+ ) }) satisfies FC diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 67a3a28..8e01fd4 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -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) => void } -export default (({ posts, onClick }: Props) => ( -
- {posts.map ((post, i) => ( - - {post.title - ))} -
)) satisfies FC +export default (({ posts, onClick }: Props) => { + const location = useLocation () + + const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) + + const cardRef = useRef (null) + + return ( + <> +
+ {posts.map ((post, i) => { + const id2 = `page-${ post.id }` + const layoutId = id2 + + return ( + { + const sharedId = `page-${ post.id }` + setForLocationKey (location.key, sharedId) + onClick?.(e) + }}> + { + 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 }}> + {post.title + + ) + })} +
+ ) +}) satisfies FC diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx new file mode 100644 index 0000000..0aebe18 --- /dev/null +++ b/frontend/src/components/PrefetchLink.tsx @@ -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 & { + to: To + state?: Record + replace?: boolean + className?: string + cancelOnError?: boolean } + + +export default forwardRef (({ + 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) => { + onMouseEnter?.(ev) + await doPrefetch () + } + + const handleTouchStart = async (ev: TouchEvent) => { + onTouchStart?.(ev) + await doPrefetch () + } + + const handleClick = async (ev: MouseEvent) => { + 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 ( + ) +}) diff --git a/frontend/src/components/RouteBlockerOverlay.tsx b/frontend/src/components/RouteBlockerOverlay.tsx new file mode 100644 index 0000000..50f46f3 --- /dev/null +++ b/frontend/src/components/RouteBlockerOverlay.tsx @@ -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 (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 ( +
+
+
+ Loading... +
+
+
) +}) satisfies FC diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 3ef86c8..9147e45 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -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> @@ -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 ? ( - - {tag.name} - ) + {tag.name} + + : + {tag.name} + ) : ( diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index b8f5f07..9959e47 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -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 -type Props = { posts: Post[] } +type Props = { posts: Post[] + onClick?: (event: MouseEvent) => 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 => (
  • - +
  • ))) : [])} 関聯 diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 9762a3d..0158ddd 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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) => {