diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b241081..1d2dcb3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,10 +13,12 @@ "@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", "clsx": "^2.1.1", + "framer-motion": "^12.23.22", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", @@ -25,7 +27,7 @@ "react-helmet-async": "^2.0.5", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", - "react-router-dom": "^6.30.0", + "react-router-dom": "^6.30.1", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0" @@ -1908,6 +1910,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", @@ -3573,6 +3601,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.21", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5216,6 +5271,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5891,9 +5961,9 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0" @@ -5906,13 +5976,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "react-router": "6.30.1" }, "engines": { "node": ">=14.0.0" diff --git a/frontend/package.json b/frontend/package.json index 747f7ed..ec2923b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,10 +15,12 @@ "@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", "clsx": "^2.1.1", + "framer-motion": "^12.23.22", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", @@ -27,7 +29,7 @@ "react-helmet-async": "^2.0.5", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", - "react-router-dom": "^6.30.0", + "react-router-dom": "^6.30.1", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1b1e28..c7becbe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,12 @@ import axios from 'axios' import toCamel from 'camelcase-keys' +import { AnimatePresence, LayoutGroup } from 'framer-motion' 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 TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' @@ -20,11 +25,40 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' -import type { FC } from 'react' +import type { Dispatch, FC, SetStateAction } from 'react' import type { User } from '@/types' +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + const location = useLocation () + + return ( + + + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + ) +} + + export default (() => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) @@ -74,22 +108,7 @@ export default (() => {
- - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - +
) diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 67a3a28..1b4b40a 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -1,4 +1,9 @@ -import { Link } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { useRef } from 'react' +import { Link, useNavigate } from 'react-router-dom' + +import { fetchPost } from '@/lib/posts' import type { FC, MouseEvent } from 'react' @@ -8,18 +13,73 @@ type Props = { posts: Post[] onClick?: (event: MouseEvent) => void } -export default (({ posts, onClick }: Props) => ( -
- {posts.map ((post, i) => ( - - {post.title - ))} -
)) satisfies FC +export default (({ posts, onClick }: Props) => { + const navigate = useNavigate () + + const qc = useQueryClient () + + const prefetch = (id: string) => qc.prefetchQuery ({ + queryKey: ['post', id], + queryFn: () => fetchPost (id) }) + + return ( +
+ {posts.map ((post, i) => { + const id = String (post.id) + const hRef = `/posts/${ id }` + const cardRef = useRef (null) + + const handleClick = async (ev: MouseEvent) => { + onClick?.(ev) + + if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1) + return + + ev.preventDefault () + + await qc.ensureQueryData ({ + queryKey: ['post', id], + queryFn: () => fetchPost (id) }) + + navigate (hRef) + } + + return ( + prefetch (id)} + onFocus={() => prefetch (id)} + onClick={handleClick}> + { + if (cardRef.current) + { + cardRef.current.style.position = 'relative' + cardRef.current.style.zIndex = '9999' + } + }} + onLayoutAnimationComplete={() => { + if (cardRef.current) + { + cardRef.current.style.zIndex = '' + cardRef.current.style.position = '' + } + }} + transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}> + {post.title + + ) + })} +
) +}) satisfies FC diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index c2117a3..1067101 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -1,9 +1,13 @@ -import React from 'react' +import { cn } from '@/lib/utils' -type Props = { children: React.ReactNode } +import type { FC, ReactNode } from 'react' +type Props = { + children: ReactNode + className?: string } -export default ({ children }: Props) => ( -
+ +export default (({ children, className }: Props) => ( +
{children} -
) +
)) satisfies FC diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts new file mode 100644 index 0000000..62cec04 --- /dev/null +++ b/frontend/src/lib/posts.ts @@ -0,0 +1,23 @@ +import axios from 'axios' +import toCamel from 'camelcase-keys' + +import { API_BASE_URL } from '@/config' + +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 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/main.tsx b/frontend/src/main.tsx index e823685..64630d2 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..7426c72 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -1,5 +1,5 @@ -import axios from 'axios' -import toCamel from 'camelcase-keys' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -12,14 +12,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 +28,46 @@ type Props = { user: User | null } export default (({ user }: Props) => { const { id } = useParams () - const [post, setPost] = useState (null) + const qc = useQueryClient () + + const { data: post, isError: errorFlg, error } = useQuery ({ + enabled: Boolean (id), + queryKey: ['post', String (id)], + queryFn: () => fetchPost (String (id)) }) + 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 +88,29 @@ export default (({ user }: Props) => { )} {post && {`${ post.title || post.url } | ${ SITE_TITLE }`}} +
- +
- + + + + {post?.url}/ + + {post ? ( <> - @@ -112,7 +124,10 @@ export default (({ user }: Props) => { { - setPost (newPost) + qc.setQueryData (['post', String (id)], + (prev: any) => newPost ?? prev) + qc.invalidateQueries ({ queryKey: ['posts'] }) + qc.invalidateQueries ({ queryKey: ['related', String (id)] }) toast ({ description: '更新しました.' }) }}/> )} @@ -120,8 +135,9 @@ export default (({ user }: Props) => { ) : 'Loading...'} +
- +
) }) satisfies FC