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