From d5d7e0e22b5f1a9e6259e52d642b6ac74fe03fe1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Oct 2025 03:15:46 +0900 Subject: [PATCH 1/5] #140 --- frontend/package-lock.json | 70 +++++++++++++- frontend/package.json | 5 +- frontend/src/App.tsx | 48 +++++----- frontend/src/components/PostList.tsx | 6 +- frontend/src/components/PrefetchLink.tsx | 83 ++++++++++++++++ .../src/components/RouteBlockerOverlay.tsx | 46 +++++++++ frontend/src/lib/posts.ts | 37 ++++++++ frontend/src/lib/prefetchers.ts | 45 +++++++++ frontend/src/main.tsx | 11 ++- frontend/src/pages/posts/PostDetailPage.tsx | 94 ++++++++++--------- 10 files changed, 372 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/PrefetchLink.tsx create mode 100644 frontend/src/components/RouteBlockerOverlay.tsx create mode 100644 frontend/src/lib/posts.ts create mode 100644 frontend/src/lib/prefetchers.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b241081..afbc7f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,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", @@ -20,6 +21,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", @@ -28,7 +30,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -1908,6 +1911,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", @@ -5452,6 +5481,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", @@ -7186,6 +7225,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 747f7ed..739431f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,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", @@ -22,6 +23,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", @@ -30,7 +32,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1b1e28..b4c9d86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import toCamel from 'camelcase-keys' import { useEffect, useState } from 'react' import { BrowserRouter, Navigate, Route, Routes } 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' @@ -71,26 +72,29 @@ export default (() => { } return ( - -
- - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - -
- -
) + <> + + +
+ + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + +
+ +
+ ) }) satisfies FC diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 67a3a28..87aacc8 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom' +import PrefetchLink from '@/components/PrefetchLink' import type { FC, MouseEvent } from 'react' @@ -11,7 +11,7 @@ type Props = { posts: Post[] export default (({ posts, onClick }: Props) => (
{posts.map ((post, i) => ( - @@ -21,5 +21,5 @@ export default (({ posts, onClick }: Props) => ( loading={i < 12 ? 'eager' : 'lazy'} 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..4cdbe9c --- /dev/null +++ b/frontend/src/components/PrefetchLink.tsx @@ -0,0 +1,83 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' + +import { useOverlayStore } from '@/components/RouteBlockerOverlay' +import { prefetchForURL } from '@/lib/prefetchers' +import { cn } from '@/lib/utils' + +import type { AnchorHTMLAttributes, FC, MouseEvent, TouchEvent } from 'react' + +type Props = AnchorHTMLAttributes & { + to: string + replace?: boolean + className?: string + cancelOnError?: boolean } + + +export default (({ to, + replace, + className, + onMouseEnter, + onTouchStart, + onClick, + cancelOnError = false, + ...rest }: Props) => { + const navigate = useNavigate () + const qc = useQueryClient () + const url = useMemo (() => (new URL (to, 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) + doPrefetch () + } + + const handleTouchStart = async (ev: TouchEvent) => { + onTouchStart?.(ev) + 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 ( + ) +}) satisfies FC 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/lib/posts.ts b/frontend/src/lib/posts.ts new file mode 100644 index 0000000..e358158 --- /dev/null +++ b/frontend/src/lib/posts.ts @@ -0,0 +1,37 @@ +import axios from 'axios' +import toCamel from 'camelcase-keys' + +import { API_BASE_URL } from '@/config' + +import type { Post } from '@/types' + + +export const fetchPosts = async ({ tags, match, limit, cursor }: { + tags: string + match: 'any' | 'all' + limit: number + cursor?: string }): Promise<{ posts: Post[]; nextCursor: string }> => { + const res = await axios.get (`${ API_BASE_URL }/posts`, { + params: { tags, match, limit, ...(cursor && { cursor }) } }) + + return toCamel (res.data as any, { deep: true }) as { posts: Post[] + nextCursor: string } +} + + +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 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) +} diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts new file mode 100644 index 0000000..2a0a7b1 --- /dev/null +++ b/frontend/src/lib/prefetchers.ts @@ -0,0 +1,45 @@ +import { QueryClient } from '@tanstack/react-query' +import { match } from 'path-to-regexp' + +import { fetchPost, fetchPosts } from '@/lib/posts' + +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 match = url.searchParams.get ('match') === 'any' ? 'any' : 'all' + const limit = Number (url.searchParams.get ('limit') || 20) + await qc.prefetchQuery ({ + queryKey: ['posts', 'index', { tags, match, limit }], + queryFn: () => fetchPosts ({ tags, match, limit }) }) +} + + +const prefetchPostShow: Prefetcher = async (qc, url) => { + const m = mPost (url.pathname) + if (!(m)) + return + + const { id } = m.params + await qc.prefetchQuery ({ + queryKey: ['posts', 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/main.tsx b/frontend/src/main.tsx index e823685..c7fbdb2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot } from 'react-dom/client' import { HelmetProvider } from 'react-helmet-async' @@ -6,7 +7,15 @@ import App from '@/App' const helmetContext = { } +const client = new QueryClient ({ + defaultOptions: { + queries: { staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + retry: 1 }}}) + createRoot (document.getElementById ('root')!).render ( - + + + ) diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 2955530..9f93a12 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -1,5 +1,4 @@ -import axios from 'axios' -import toCamel from 'camelcase-keys' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -12,14 +11,15 @@ 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 { API_BASE_URL, SITE_TITLE } from '@/config' +import { SITE_TITLE } from '@/config' +import { fetchPost, toggleViewedFlg } from '@/lib/posts' import { cn } from '@/lib/utils' import NotFound from '@/pages/NotFound' import ServiceUnavailable from '@/pages/ServiceUnavailable' import type { FC } from 'react' -import type { Post, User } from '@/types' +import type { User } from '@/types' type Props = { user: User | null } @@ -27,49 +27,46 @@ type Props = { user: User | null } export default (({ user }: Props) => { const { id } = useParams () - const [post, setPost] = useState (null) + const { data: post, isError: errorFlg, error } = useQuery ({ + enabled: Boolean (id), + queryKey: ['posts', String (id)], + queryFn: () => fetchPost (String (id)) }) + + const qc = useQueryClient () + const [status, setStatus] = useState (200) - const changeViewedFlg = async () => { - const url = `${ API_BASE_URL }/posts/${ id }/viewed` - const opt = { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } - try - { - if (post!.viewed) - await axios.delete (url, opt) - else - await axios.post (url, { }, opt) - - // 通信に成功したら “閲覧済” をトグル - setPost (post => ({ ...post!, viewed: !(post!.viewed) })) - } - catch - { - toast ({ title: '失敗……', description: '通信に失敗しました……' }) - } - } + const changeViewedFlg = useMutation ({ + mutationFn: async () => { + const next = !(post!.viewed) + await toggleViewedFlg (id!, next) + return next + }, + onMutate: async () => { + await qc.cancelQueries ({ queryKey: ['post', String (id)] }) + const prev = qc.getQueryData (['post', String (id)]) + qc.setQueryData (['post', String (id)], + (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) + return { prev } + }, + onError: (...[, , ctx]) => { + if (ctx?.prev) + qc.setQueryData (['post', String (id)], ctx.prev) + toast ({ title: '失敗……', description: '通信に失敗しました……' }) + }, + onSuccess: () => { + qc.invalidateQueries ({ queryKey: ['posts'] }) + qc.invalidateQueries ({ queryKey: ['related', String (id)] }) + } }) useEffect (() => { - setPost (null) - - if (!(id)) + if (!(errorFlg)) return - const fetchPost = async () => { - try - { - const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { - 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) - setPost (toCamel (res.data as any, { deep: true }) as Post) - } - catch (err) - { - if (axios.isAxiosError (err)) - setStatus (err.status ?? 200) - } - } - fetchPost () - }, [id]) + const code = (error as any)?.response.status ?? (error as any)?.status + if (code) + setStatus (code) + }, [errorFlg, error]) switch (status) { @@ -90,15 +87,18 @@ export default (({ user }: Props) => { )} {post && {`${ post.title || post.url } | ${ SITE_TITLE }`}} +
- +
+ {post ? ( <> - @@ -112,7 +112,10 @@ export default (({ user }: Props) => { { - setPost (newPost) + qc.setQueryData (['posts', String (id)], + (prev: any) => newPost ?? prev) + qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) + qc.invalidateQueries ({ queryKey: ['related', String (id)] }) toast ({ description: '更新しました.' }) }}/> )} @@ -120,8 +123,9 @@ export default (({ user }: Props) => { ) : 'Loading...'} +
- +
) }) satisfies FC From 5cbe21b5d7f4755bd16e4fdc5b105e3f1f5e238d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Oct 2025 12:52:43 +0900 Subject: [PATCH 2/5] #140 --- frontend/src/components/PostList.tsx | 9 +++++---- frontend/src/components/PrefetchLink.tsx | 14 +++++++------- frontend/src/pages/posts/PostDetailPage.tsx | 10 +++++----- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 87aacc8..1856bc3 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -11,10 +11,11 @@ type Props = { posts: Post[] export default (({ posts, onClick }: Props) => (
{posts.map ((post, i) => ( - + {post.title & { export default (({ to, - replace, - className, - onMouseEnter, - onTouchStart, - onClick, - cancelOnError = false, - ...rest }: Props) => { + replace, + className, + onMouseEnter, + onTouchStart, + onClick, + cancelOnError = false, + ...rest }: Props) => { const navigate = useNavigate () const qc = useQueryClient () const url = useMemo (() => (new URL (to, location.origin)).toString (), [to]) diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 9f93a12..de4a298 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -43,19 +43,19 @@ export default (({ user }: Props) => { 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: ['posts', String (id)] }) + const prev = qc.getQueryData (['posts', String (id)]) + qc.setQueryData (['posts', String (id)], (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) return { prev } }, onError: (...[, , ctx]) => { if (ctx?.prev) - qc.setQueryData (['post', String (id)], ctx.prev) + qc.setQueryData (['posts', String (id)], ctx.prev) toast ({ title: '失敗……', description: '通信に失敗しました……' }) }, onSuccess: () => { - qc.invalidateQueries ({ queryKey: ['posts'] }) + qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) qc.invalidateQueries ({ queryKey: ['related', String (id)] }) } }) From 214c91e3bf5aaf5f08d932c5e88b07aaae2ce357 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Oct 2025 17:05:05 +0900 Subject: [PATCH 3/5] #140 --- frontend/src/App.tsx | 11 +++++++++-- frontend/src/pages/posts/PostDetailPage.tsx | 11 ++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4c9d86..2248239 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import axios from 'axios' import toCamel from 'camelcase-keys' 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' @@ -26,6 +26,13 @@ import type { FC } from 'react' import type { User } from '@/types' +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) @@ -81,7 +88,7 @@ export default (() => { }/> }/> }/> - }/> + }/> }/> }/> }/> diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index de4a298..c7315b6 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -28,9 +28,10 @@ export default (({ user }: Props) => { const { id } = useParams () const { data: post, isError: errorFlg, error } = useQuery ({ - enabled: Boolean (id), - queryKey: ['posts', String (id)], - queryFn: () => fetchPost (String (id)) }) + enabled: Boolean (id), + queryKey: ['posts', String (id)], + queryFn: () => fetchPost (String (id)), + placeholderData: undefined }) const qc = useQueryClient () @@ -68,6 +69,10 @@ export default (({ user }: Props) => { setStatus (code) }, [errorFlg, error]) + useEffect (() => { + setStatus (200) + }, [id]) + switch (status) { case 404: From 7df51fb34b74e944f25cef548c3807d4f4a97393 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Dec 2025 17:21:58 +0900 Subject: [PATCH 4/5] =?UTF-8?q?#140=20=E3=81=BC=E3=81=A1=E3=81=BC=E3=81=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/PrefetchLink.tsx | 12 ++++++++---- frontend/src/components/TagLink.tsx | 19 +++++++++++++++---- frontend/src/components/TagSidebar.tsx | 13 +++++++------ frontend/src/pages/posts/PostListPage.tsx | 21 +++++++++++++-------- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 01b53b6..b968c8e 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -1,15 +1,16 @@ import { useQueryClient } from '@tanstack/react-query' import { useMemo } from 'react' -import { useNavigate } from 'react-router-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, FC, MouseEvent, TouchEvent } from 'react' +import type { To } from 'react-router-dom' type Props = AnchorHTMLAttributes & { - to: string + to: To replace?: boolean className?: string cancelOnError?: boolean } @@ -25,7 +26,10 @@ export default (({ to, ...rest }: Props) => { const navigate = useNavigate () const qc = useQueryClient () - const url = useMemo (() => (new URL (to, location.origin)).toString (), [to]) + 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 () => { @@ -74,7 +78,7 @@ export default (({ to, } return ( - > @@ -24,6 +26,7 @@ export default (({ tag, linkFlg = true, withWiki = true, withCount = true, + prefetch = false, ...props }: Props) => { const spanClass = cn ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, @@ -44,11 +47,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 a2e00b2..d1b2acc 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -10,16 +10,17 @@ import { API_BASE_URL } from '@/config' import { CATEGORIES } from '@/consts' import { cn } from '@/lib/utils' -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) @@ -64,11 +65,11 @@ export default (({ posts }: Props) => {
タグ
    - {CATEGORIES.flatMap (cat => cat in tags ? ( + {CATEGORIES.flatMap (cat => cat in tags ? tags[cat].map (tag => (
  • - -
  • ))) : [])} + + )) : [])}
関聯 {posts.length > 0 && ( diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index fdda686..5b6a941 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -10,6 +10,7 @@ import WikiBody from '@/components/WikiBody' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' import { API_BASE_URL, SITE_TITLE } from '@/config' +import { fetchPosts } from '@/lib/posts' import type { Post, WikiPage } from '@/types' @@ -28,13 +29,11 @@ export default () => { const loadMore = async (withCursor: boolean) => { setLoading (true) - const res = await axios.get (`${ API_BASE_URL }/posts`, { - params: { tags: tags.join (' '), - match: anyFlg ? 'any' : 'all', - limit: '20', - ...(withCursor && { cursor }) } }) - const data = toCamel (res.data as any, { deep: true }) as { posts: Post[] - nextCursor: string } + const data = await fetchPosts ({ + tags: tags.join (' '), + match: anyFlg ? 'any' : 'all', + limit: 20, + ...(withCursor && { cursor }) }) setPosts (posts => ( [...((new Map ([...(withCursor ? posts : []), ...data.posts] .map (post => [post.id, post]))) @@ -111,7 +110,13 @@ export default () => { - + { + const statesToSave = { + posts, cursor, + scroll: containerRef.current?.scrollTop ?? 0 } + sessionStorage.setItem (`posts:${ tagsQuery }`, + JSON.stringify (statesToSave)) + }}/> From 8cc2a88e7cdf7a6facb7f931b82ffb01fbeb79d8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 1 Feb 2026 03:59:39 +0900 Subject: [PATCH 5/5] #140 --- frontend/src/components/PrefetchLink.tsx | 30 +++--- frontend/src/components/TopNav.tsx | 83 ++++++++-------- frontend/src/components/common/Pagination.tsx | 10 +- frontend/src/lib/api.ts | 65 ++++++++++++ frontend/src/lib/posts.ts | 40 ++------ frontend/src/lib/prefetchers.ts | 14 ++- frontend/src/lib/queryKeys.ts | 10 ++ frontend/src/lib/tags.ts | 7 ++ frontend/src/lib/wiki.ts | 14 +++ frontend/src/pages/posts/PostDetailPage.tsx | 42 ++++---- frontend/src/pages/posts/PostListPage.tsx | 98 ++++--------------- frontend/src/pages/wiki/WikiDetailPage.tsx | 37 +++---- 12 files changed, 235 insertions(+), 215 deletions(-) create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/queryKeys.ts create mode 100644 frontend/src/lib/tags.ts create mode 100644 frontend/src/lib/wiki.ts diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index b968c8e..f983b0d 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -1,12 +1,12 @@ import { useQueryClient } from '@tanstack/react-query' -import { useMemo } from 'react' +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, FC, MouseEvent, TouchEvent } from 'react' +import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { To } from 'react-router-dom' type Props = AnchorHTMLAttributes & { @@ -16,14 +16,15 @@ type Props = AnchorHTMLAttributes & { cancelOnError?: boolean } -export default (({ to, - replace, - className, - onMouseEnter, - onTouchStart, - onClick, - cancelOnError = false, - ...rest }: Props) => { +export default forwardRef (({ + to, + replace, + className, + onMouseEnter, + onTouchStart, + onClick, + cancelOnError = false, + ...rest }, ref) => { const navigate = useNavigate () const qc = useQueryClient () const url = useMemo (() => { @@ -47,12 +48,12 @@ export default (({ to, const handleMouseEnter = async (ev: MouseEvent) => { onMouseEnter?.(ev) - doPrefetch () + await doPrefetch () } const handleTouchStart = async (ev: TouchEvent) => { onTouchStart?.(ev) - doPrefetch () + await doPrefetch () } const handleClick = async (ev: MouseEvent) => { @@ -78,10 +79,11 @@ export default (({ to, } return ( -
) -}) satisfies FC +}) 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) => {