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) => { - ぼざクリ タグ広場 - + { opacity: hl.visible ? 1 : 0 }}/> {menu.map ((item, i) => ( - { - itemsRef.current[i] = el - }} - className={cn ('relative z-10 flex h-full items-center px-5', - (i === openItemIdx) && 'font-bold')}> + { + itemsRef.current[i] = el + }} + className={cn ('relative z-10 flex h-full items-center px-5', + (i === openItemIdx) && 'font-bold')}> {item.name} - ))} + ))} @@ -203,11 +201,12 @@ export default (({ user }: Props) => { 'component' in item ? {item.component} : ( - + {item.name} - )))} + )))} @@ -229,19 +228,20 @@ export default (({ user }: Props) => { {menu.map ((item, i) => ( - { - if (i !== openItemIdx) - { - ev.preventDefault () - setOpenItemIdx (i) - } - }}> + ) => { + if (i !== openItemIdx) + { + ev.preventDefault () + setOpenItemIdx (i) + } + }}> {item.name} - + {i === openItemIdx && ( @@ -267,11 +267,12 @@ export default (({ user }: Props) => { {subItem.component} ) : ( - + {subItem.name} - )))} + )))} )} ))} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx index 5fb527e..fc7dbde 100644 --- a/frontend/src/components/common/Pagination.tsx +++ b/frontend/src/components/common/Pagination.tsx @@ -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 }) => { {(page > 1) - ? < + ? < : <} {pages.map ((p, idx) => ( @@ -69,10 +71,10 @@ export default (({ page, totalPages, siblingCount = 4 }) => { ? … : ((p === page) ? {p} - : {p})))} + : {p})))} {(page < totalPages) - ? > + ? > : >} ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..9695b0a --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,65 @@ +import axios from 'axios' +import toCamel from 'camelcase-keys' + +import { API_BASE_URL } from '@/config' + +type Opt = { + params?: Record + headers?: Record } + +const client = axios.create ({ baseURL: API_BASE_URL }) + + +const withUserCode = (opt?: Opt): Opt => ({ + ...opt, + headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', + ...(opt?.headers ?? { }) } }) + + +const apiP = async ( + method: 'post' | 'put' | 'patch', + path: string, + body?: unknown, + opt?: Opt, +): Promise => { + const res = await client[method] (path, body ?? { }, withUserCode (opt)) + return toCamel (res.data as any, { deep: true }) as T +} + + +export const apiGet = async ( + path: string, + opt?: Opt, +): Promise => { + const res = await client.get (path, withUserCode (opt)) + return toCamel (res.data as any, { deep: true }) as T +} + + +export const apiPost = async ( + path: string, + body?: unknown, + opt?: Opt, +): Promise => apiP ('post', path, body, opt) + + +export const apiPut = async ( + path: string, + body?: unknown, + opt?: Opt, +): Promise => apiP ('put', path, body, opt) + + +export const apiPatch = async ( + path: string, + body?: unknown, + opt?: Opt, +): Promise => apiP ('patch', path, body, opt) + + +export const apiDelete = async ( + path: string, + opt?: Opt, +): Promise => { + await client.delete (path, withUserCode (opt)) +} diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 62cec04..31010aa 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,23 +1,30 @@ -import axios from 'axios' -import toCamel from 'camelcase-keys' - -import { API_BASE_URL } from '@/config' +import { apiDelete, apiGet, apiPost } from '@/lib/api' import type { Post } from '@/types' -export const fetchPost = async (id: string): Promise => { - const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { - headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) - return toCamel (res.data as any, { deep: true }) as Post -} +export const fetchPosts = async ( + { tags, match, page, limit, cursor }: { + tags: string + match: 'any' | 'all' + page?: number + limit?: number + cursor?: string } +): Promise<{ + posts: Post[] + count: number + nextCursor: string }> => await apiGet ('/posts', { + params: { + tags, + match, + ...(page && { page }), + ...(limit && { limit }), + ...(cursor && { cursor }) } }) + + +export const fetchPost = async (id: string): Promise => await apiGet (`/posts/${ id }`) export const toggleViewedFlg = async (id: string, viewed: boolean): Promise => { - const url = `${ API_BASE_URL }/posts/${ id }/viewed` - const opt = { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } } - if (viewed) - await axios.post (url, { }, opt) - else - await axios.delete (url, opt) + await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) } diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts new file mode 100644 index 0000000..8e0c30b --- /dev/null +++ b/frontend/src/lib/prefetchers.ts @@ -0,0 +1,49 @@ +import { QueryClient } from '@tanstack/react-query' +import { match } from 'path-to-regexp' + +import { fetchPost, fetchPosts } from '@/lib/posts' +import { postsKeys } from '@/lib/queryKeys' + +type Prefetcher = (qc: QueryClient, url: URL) => Promise + +const mPost = match<{ id: string }> ('/posts/:id') + + +const prefetchPostsIndex: Prefetcher = async (qc, url) => { + const tags = url.searchParams.get ('tags') ?? '' + const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all' + const page = Number (url.searchParams.get ('page') || 1) + const limit = Number (url.searchParams.get ('limit') || 20) + await qc.prefetchQuery ({ + queryKey: postsKeys.index ({ tags, match: m, page, limit }), + queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) +} + + +const prefetchPostShow: Prefetcher = async (qc, url) => { + const m = mPost (url.pathname) + if (!(m)) + return + + const { id } = m.params + await qc.prefetchQuery ({ + queryKey: postsKeys.show (id), + queryFn: () => fetchPost (id) }) +} + + +export const routePrefetchers: { + test: (u: URL) => boolean + run: Prefetcher }[] = [ + { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, + { test: u => Boolean (mPost (u.pathname)), run: prefetchPostShow }] + + +export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise => { + const u = new URL (urlLike, location.origin) + const jobs = routePrefetchers.filter (r => r.test (u)).map (r => r.run (qc, u)) + if (jobs.length === 0) + return + + await Promise.all (jobs) +} diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts new file mode 100644 index 0000000..037f338 --- /dev/null +++ b/frontend/src/lib/queryKeys.ts @@ -0,0 +1,10 @@ +export const postsKeys = { + root: ['posts'] as const, + index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => + ['posts', 'index', p] as const, + show: (id: string) => ['posts', id] as const, + related: (id: string) => ['related', id] as const } + +export const wikiKeys = { + root: ['wiki'] as const, + show: (title: string, p: { version: string }) => ['wiki', title, p] as const } diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts new file mode 100644 index 0000000..ce7650b --- /dev/null +++ b/frontend/src/lib/tags.ts @@ -0,0 +1,7 @@ +import { apiGet } from '@/lib/api' + +import type { Tag } from '@/types' + + +export const fetchTagByName = async (name: string): Promise => + await apiGet (`/tags/name/${ name }`) diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts new file mode 100644 index 0000000..c2b9d30 --- /dev/null +++ b/frontend/src/lib/wiki.ts @@ -0,0 +1,14 @@ +import { apiGet } from '@/lib/api' + +import type { WikiPage } from '@/types' + + +export const fetchWikiPage = async (id: string): Promise => + await apiGet (`/wiki/${ id }`) + + +export const fetchWikiPageByTitle = async ( + title: string, + { version }: { version?: string }, +): Promise => + await apiGet (`/wiki/title/${ title }`, { params: version ? { version } : { } }) diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 7426c72..7edec9b 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -4,16 +4,17 @@ import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' -import PostList from '@/components/PostList' -import TagDetailSidebar from '@/components/TagDetailSidebar' import PostEditForm from '@/components/PostEditForm' import PostEmbed from '@/components/PostEmbed' +import PostList from '@/components/PostList' +import TagDetailSidebar from '@/components/TagDetailSidebar' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { fetchPost, toggleViewedFlg } from '@/lib/posts' +import { postsKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import NotFound from '@/pages/NotFound' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -27,37 +28,39 @@ type Props = { user: User | null } export default (({ user }: Props) => { const { id } = useParams () - - const qc = useQueryClient () + const postId = String (id ?? '') + const postKey = postsKeys.show (postId) const { data: post, isError: errorFlg, error } = useQuery ({ enabled: Boolean (id), - queryKey: ['post', String (id)], - queryFn: () => fetchPost (String (id)) }) + queryKey: postKey, + queryFn: () => fetchPost (postId) }) + + const qc = useQueryClient () const [status, setStatus] = useState (200) const changeViewedFlg = useMutation ({ mutationFn: async () => { - const next = !(post!.viewed) - await toggleViewedFlg (id!, next) + const cur = qc.getQueryData (postKey) + const next = !(cur?.viewed) + await toggleViewedFlg (postId, next) return next }, onMutate: async () => { - await qc.cancelQueries ({ queryKey: ['post', String (id)] }) - const prev = qc.getQueryData (['post', String (id)]) - qc.setQueryData (['post', String (id)], + await qc.cancelQueries ({ queryKey: postKey }) + const prev = qc.getQueryData (postKey) + qc.setQueryData (postKey, (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) return { prev } }, onError: (...[, , ctx]) => { if (ctx?.prev) - qc.setQueryData (['post', String (id)], ctx.prev) + qc.setQueryData (postKey, ctx.prev) toast ({ title: '失敗……', description: '通信に失敗しました……' }) }, onSuccess: () => { - qc.invalidateQueries ({ queryKey: ['posts'] }) - qc.invalidateQueries ({ queryKey: ['related', String (id)] }) + qc.invalidateQueries ({ queryKey: postsKeys.root }) } }) useEffect (() => { @@ -69,6 +72,10 @@ export default (({ user }: Props) => { setStatus (code) }, [errorFlg, error]) + useEffect (() => { + setStatus (200) + }, [id]) + switch (status) { case 404: @@ -122,14 +129,14 @@ export default (({ user }: Props) => { {['admin', 'member'].some (r => user?.role === r) && ( - { - qc.setQueryData (['post', String (id)], - (prev: any) => newPost ?? prev) - qc.invalidateQueries ({ queryKey: ['posts'] }) - qc.invalidateQueries ({ queryKey: ['related', String (id)] }) - toast ({ description: '更新しました.' }) - }}/> + { + qc.setQueryData (postsKeys.show (postId), + (prev: any) => newPost ?? prev) + qc.invalidateQueries ({ queryKey: postsKeys.root }) + toast ({ description: '更新しました.' }) + }}/> )} >) diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 50cc04f..1440985 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -1,97 +1,46 @@ -import axios from 'axios' -import toCamel from 'camelcase-keys' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useLayoutEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { Link, useLocation, useNavigationType } from 'react-router-dom' +import { useLocation } from 'react-router-dom' import PostList from '@/components/PostList' +import PrefetchLink from '@/components/PrefetchLink' import TagSidebar from '@/components/TagSidebar' import WikiBody from '@/components/WikiBody' import Pagination from '@/components/common/Pagination' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' -import { API_BASE_URL, SITE_TITLE } from '@/config' +import { SITE_TITLE } from '@/config' +import { fetchPosts } from '@/lib/posts' +import { postsKeys } from '@/lib/queryKeys' +import { fetchWikiPageByTitle } from '@/lib/wiki' -import type { Post, WikiPage } from '@/types' +import type { WikiPage } from '@/types' export default () => { - const navigationType = useNavigationType () - const containerRef = useRef (null) - const loaderRef = useRef (null) - const [cursor, setCursor] = useState ('') - const [loading, setLoading] = useState (false) - const [posts, setPosts] = useState ([]) - const [totalPages, setTotalPages] = useState (0) const [wikiPage, setWikiPage] = useState (null) - const loadMore = async (withCursor: boolean) => { - setLoading (true) - - const res = await axios.get (`${ API_BASE_URL }/posts`, { - params: { tags: tags.join (' '), - match: anyFlg ? 'any' : 'all', - ...(page && { page }), - ...(limit && { limit }), - ...(withCursor && { cursor }) } }) - const data = toCamel (res.data as any, { deep: true }) as { - posts: Post[] - count: number - nextCursor: string } - setPosts (posts => ( - [...((new Map ([...(withCursor ? posts : []), ...data.posts] - .map (post => [post.id, post]))) - .values ())])) - setCursor (data.nextCursor) - setTotalPages (Math.ceil (data.count / limit)) - - setLoading (false) - } - const location = useLocation () const query = new URLSearchParams (location.search) const tagsQuery = query.get ('tags') ?? '' const anyFlg = query.get ('match') === 'any' + const match = anyFlg ? 'any' : 'all' const tags = tagsQuery.split (' ').filter (e => e !== '') + const tagsKey = tags.join (' ') const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) - useEffect(() => { - const observer = new IntersectionObserver (entries => { - if (entries[0].isIntersecting && !(loading) && cursor) - loadMore (true) - }, { threshold: 1 }) - - const target = loaderRef.current - target && observer.observe (target) - - return () => { - target && observer.unobserve (target) - } - }, [loaderRef, loading]) + const { data, isLoading: loading } = useQuery ({ + queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }), + queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) }) + const posts = data?.posts ?? [] + const cursor = data?.nextCursor ?? '' + const totalPages = data ? Math.ceil (data.count / limit) : 0 useLayoutEffect (() => { - // TODO: 無限ロード用 - const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null - if (savedState && navigationType === 'POP') - { - const { posts, cursor, scroll } = JSON.parse (savedState) - setPosts (posts) - setCursor (cursor) - - if (containerRef.current) - containerRef.current.scrollTop = scroll - - loadMore (true) - } - else - { - setPosts ([]) - loadMore (false) - } - setWikiPage (null) if (tags.length === 1) { @@ -99,8 +48,7 @@ export default () => { try { const tagName = tags[0] - const res = await axios.get (`${ API_BASE_URL }/wiki/title/${ tagName }`) - setWikiPage (toCamel (res.data as any, { deep: true }) as WikiPage) + setWikiPage (await fetchWikiPageByTitle (tagName, { })) } catch { @@ -120,7 +68,13 @@ export default () => { - + { + const statesToSave = { + posts, cursor, + scroll: containerRef.current?.scrollTop ?? 0 } + sessionStorage.setItem (`posts:${ tagsQuery }`, + JSON.stringify (statesToSave)) + }}/> @@ -128,28 +82,19 @@ export default () => { {posts.length > 0 ? ( <> - { - // TODO: 無限ロード用なので復活時に戻す. - // const statesToSave = { - // posts, cursor, - // scroll: containerRef.current?.scrollTop ?? 0 } - // sessionStorage.setItem (`posts:${ tagsQuery }`, - // JSON.stringify (statesToSave)) - }}/> + >) : !(loading) && '広場には何もありませんよ.'} {loading && 'Loading...'} - {/* TODO: 無限ローディング復活までコメント・アウト */} - {/* */} {tags.length === 1 && ( - + Wiki を見る - + )} diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index 6a68656..c87ff52 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -1,17 +1,19 @@ -import axios from 'axios' -import toCamel from 'camelcase-keys' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import PostList from '@/components/PostList' +import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' import WikiBody from '@/components/WikiBody' import PageTitle from '@/components/common/PageTitle' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' -import { API_BASE_URL, SITE_TITLE } from '@/config' +import { SITE_TITLE } from '@/config' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' +import { fetchPosts } from '@/lib/posts' +import { fetchTagByName } from '@/lib/tags' +import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' import type { Post, Tag, WikiPage } from '@/types' @@ -39,8 +41,7 @@ export default () => { setWikiPage (undefined) try { - const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) - const data = res.data as WikiPage + const data = await fetchWikiPage (title) navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) } catch @@ -56,10 +57,7 @@ export default () => { setWikiPage (undefined) try { - const res = await axios.get ( - `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, - { params: version ? { version } : { } }) - const data = toCamel (res.data as any, { deep: true }) as WikiPage + const data = await fetchWikiPageByTitle (title, version ? { version } : { }) if (data.title !== title) navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) setWikiPage (data) @@ -75,12 +73,7 @@ export default () => { void (async () => { try { - const res = await axios.get ( - `${ API_BASE_URL }/posts?${ new URLSearchParams ({ tags: title, - limit: '8' }) }`) - const data = toCamel (res.data as any, - { deep: true }) as { posts: Post[] - nextCursor: string } + const data = await fetchPosts ({ tags: title, match: 'all', limit: 8 }) setPosts (data.posts) } catch @@ -92,9 +85,7 @@ export default () => { void (async () => { try { - const res = await axios.get ( - `${ API_BASE_URL }/tags/name/${ encodeURIComponent (title) }`) - setTag (toCamel (res.data as any, { deep: true }) as Tag) + setTag (await fetchTagByName (title)) } catch { @@ -115,16 +106,16 @@ export default () => { {(wikiPage && version) && ( {wikiPage.pred ? ( - + < 古 - ) : '(最古)'} + ) : '(最古)'} {wikiPage.updatedAt} {wikiPage.succ ? ( - + 新 > - ) : '(最新)'} + ) : '(最新)'} )}