diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb99bed..4ad6239 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "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", @@ -34,7 +35,8 @@ "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", @@ -5579,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", @@ -7313,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 de3a5e9..df73a58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "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", @@ -36,6 +37,7 @@ "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 c207914..7e950f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { BrowserRouter, 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' @@ -43,7 +44,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - }/> + }/> }/> }/> }/> @@ -61,6 +62,13 @@ const RouteTransitionWrapper = ({ user, setUser }: { } +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) @@ -107,11 +115,14 @@ export default (() => { } return ( - -
- - -
- -
) + <> + + +
+ + +
+ +
+ ) }) satisfies FC diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 1b4b40a..3331f56 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -1,9 +1,10 @@ import { useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useRef } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { fetchPost } from '@/lib/posts' +import PrefetchLink from '@/components/PrefetchLink' import type { FC, MouseEvent } from 'react' @@ -45,12 +46,13 @@ export default (({ posts, onClick }: Props) => { } return ( - prefetch (id)} - onFocus={() => prefetch (id)} - onClick={handleClick}> + prefetch (id)} + onFocus={() => prefetch (id)} + onClick={handleClick}> { decoding="async" className="object-cover w-full h-full"/> - ) + ) })} ) }) satisfies FC diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx new file mode 100644 index 0000000..f983b0d --- /dev/null +++ b/frontend/src/components/PrefetchLink.tsx @@ -0,0 +1,89 @@ +import { useQueryClient } from '@tanstack/react-query' +import { forwardRef, useMemo } from 'react' +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 + replace?: boolean + className?: string + cancelOnError?: boolean } + + +export default forwardRef (({ + to, + replace, + className, + onMouseEnter, + onTouchStart, + onClick, + cancelOnError = false, + ...rest }, ref) => { + 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) => { + onClick?.(ev) + + if (ev.defaultPrevented + || ev.metaKey + || ev.ctrlKey + || ev.shiftKey + || ev.altKey) + return + + ev.preventDefault () + + setOverlay (true) + const ok = await doPrefetch () + setOverlay (false) + + if (!(ok) && cancelOnError) + return + + navigate (to, { replace }) + } + + 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..8256759 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,11 @@ export default (({ user }: Props) => {