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 (
+ )
+}) 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