Author | SHA1 | Message | Date |
---|---|---|---|
|
214c91e3bf | #140 | 1 week ago |
|
5cbe21b5d7 | #140 | 1 week ago |
|
d5d7e0e22b | #140 | 1 week ago |
@@ -13,6 +13,7 @@ | |||||
"@radix-ui/react-dialog": "^1.1.14", | "@radix-ui/react-dialog": "^1.1.14", | ||||
"@radix-ui/react-switch": "^1.2.5", | "@radix-ui/react-switch": "^1.2.5", | ||||
"@radix-ui/react-toast": "^1.2.14", | "@radix-ui/react-toast": "^1.2.14", | ||||
"@tanstack/react-query": "^5.90.2", | |||||
"axios": "^1.10.0", | "axios": "^1.10.0", | ||||
"camelcase-keys": "^9.1.3", | "camelcase-keys": "^9.1.3", | ||||
"class-variance-authority": "^0.7.1", | "class-variance-authority": "^0.7.1", | ||||
@@ -20,6 +21,7 @@ | |||||
"humps": "^2.0.1", | "humps": "^2.0.1", | ||||
"lucide-react": "^0.511.0", | "lucide-react": "^0.511.0", | ||||
"markdown-it": "^14.1.0", | "markdown-it": "^14.1.0", | ||||
"path-to-regexp": "^8.3.0", | |||||
"react": "^19.1.0", | "react": "^19.1.0", | ||||
"react-dom": "^19.1.0", | "react-dom": "^19.1.0", | ||||
"react-helmet-async": "^2.0.5", | "react-helmet-async": "^2.0.5", | ||||
@@ -28,7 +30,8 @@ | |||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"react-youtube": "^10.1.0", | "react-youtube": "^10.1.0", | ||||
"remark-gfm": "^4.0.1", | "remark-gfm": "^4.0.1", | ||||
"tailwind-merge": "^3.3.0" | |||||
"tailwind-merge": "^3.3.0", | |||||
"zustand": "^5.0.8" | |||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
@@ -1908,6 +1911,32 @@ | |||||
"win32" | "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": { | "node_modules/@types/axios": { | ||||
"version": "0.14.4", | "version": "0.14.4", | ||||
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", | "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", | ||||
@@ -5452,6 +5481,16 @@ | |||||
"dev": true, | "dev": true, | ||||
"license": "ISC" | "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": { | "node_modules/picocolors": { | ||||
"version": "1.1.1", | "version": "1.1.1", | ||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | ||||
@@ -7186,6 +7225,35 @@ | |||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", | ||||
"license": "MIT" | "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": { | "node_modules/zwitch": { | ||||
"version": "2.0.4", | "version": "2.0.4", | ||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", | "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", | ||||
@@ -15,6 +15,7 @@ | |||||
"@radix-ui/react-dialog": "^1.1.14", | "@radix-ui/react-dialog": "^1.1.14", | ||||
"@radix-ui/react-switch": "^1.2.5", | "@radix-ui/react-switch": "^1.2.5", | ||||
"@radix-ui/react-toast": "^1.2.14", | "@radix-ui/react-toast": "^1.2.14", | ||||
"@tanstack/react-query": "^5.90.2", | |||||
"axios": "^1.10.0", | "axios": "^1.10.0", | ||||
"camelcase-keys": "^9.1.3", | "camelcase-keys": "^9.1.3", | ||||
"class-variance-authority": "^0.7.1", | "class-variance-authority": "^0.7.1", | ||||
@@ -22,6 +23,7 @@ | |||||
"humps": "^2.0.1", | "humps": "^2.0.1", | ||||
"lucide-react": "^0.511.0", | "lucide-react": "^0.511.0", | ||||
"markdown-it": "^14.1.0", | "markdown-it": "^14.1.0", | ||||
"path-to-regexp": "^8.3.0", | |||||
"react": "^19.1.0", | "react": "^19.1.0", | ||||
"react-dom": "^19.1.0", | "react-dom": "^19.1.0", | ||||
"react-helmet-async": "^2.0.5", | "react-helmet-async": "^2.0.5", | ||||
@@ -30,7 +32,8 @@ | |||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"react-youtube": "^10.1.0", | "react-youtube": "^10.1.0", | ||||
"remark-gfm": "^4.0.1", | "remark-gfm": "^4.0.1", | ||||
"tailwind-merge": "^3.3.0" | |||||
"tailwind-merge": "^3.3.0", | |||||
"zustand": "^5.0.8" | |||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
@@ -1,8 +1,9 @@ | |||||
import axios from 'axios' | import axios from 'axios' | ||||
import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
import { useEffect, useState } from 'react' | 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' | import TopNav from '@/components/TopNav' | ||||
import { Toaster } from '@/components/ui/toaster' | import { Toaster } from '@/components/ui/toaster' | ||||
import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
@@ -25,6 +26,13 @@ import type { FC } from 'react' | |||||
import type { User } from '@/types' | import type { User } from '@/types' | ||||
const PostDetailRoute = ({ user }: { user: User | null }) => { | |||||
const location = useLocation () | |||||
const key = location.pathname | |||||
return <PostDetailPage key={key} user={user}/> | |||||
} | |||||
export default (() => { | export default (() => { | ||||
const [user, setUser] = useState<User | null> (null) | const [user, setUser] = useState<User | null> (null) | ||||
const [status, setStatus] = useState (200) | const [status, setStatus] = useState (200) | ||||
@@ -71,26 +79,29 @@ export default (() => { | |||||
} | } | ||||
return ( | return ( | ||||
<BrowserRouter> | |||||
<div className="flex flex-col h-screen w-screen"> | |||||
<TopNav user={user}/> | |||||
<Routes> | |||||
<Route path="/" element={<Navigate to="/posts" replace/>}/> | |||||
<Route path="/posts" element={<PostListPage/>}/> | |||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> | |||||
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | |||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||||
<Route path="/wiki" element={<WikiSearchPage/>}/> | |||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> | |||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/> | |||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/> | |||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/> | |||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/> | |||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> | |||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> | |||||
<Route path="*" element={<NotFound/>}/> | |||||
</Routes> | |||||
</div> | |||||
<Toaster/> | |||||
</BrowserRouter>) | |||||
<> | |||||
<RouteBlockerOverlay/> | |||||
<BrowserRouter> | |||||
<div className="flex flex-col h-screen w-screen"> | |||||
<TopNav user={user}/> | |||||
<Routes> | |||||
<Route path="/" element={<Navigate to="/posts" replace/>}/> | |||||
<Route path="/posts" element={<PostListPage/>}/> | |||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> | |||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | |||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||||
<Route path="/wiki" element={<WikiSearchPage/>}/> | |||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> | |||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/> | |||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/> | |||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/> | |||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/> | |||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> | |||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> | |||||
<Route path="*" element={<NotFound/>}/> | |||||
</Routes> | |||||
</div> | |||||
<Toaster/> | |||||
</BrowserRouter> | |||||
</>) | |||||
}) satisfies FC | }) satisfies FC |
@@ -1,4 +1,4 @@ | |||||
import { Link } from 'react-router-dom' | |||||
import PrefetchLink from '@/components/PrefetchLink' | |||||
import type { FC, MouseEvent } from 'react' | import type { FC, MouseEvent } from 'react' | ||||
@@ -11,15 +11,16 @@ type Props = { posts: Post[] | |||||
export default (({ posts, onClick }: Props) => ( | export default (({ posts, onClick }: Props) => ( | ||||
<div className="flex flex-wrap gap-6 p-4"> | <div className="flex flex-wrap gap-6 p-4"> | ||||
{posts.map ((post, i) => ( | {posts.map ((post, i) => ( | ||||
<Link to={`/posts/${ post.id }`} | |||||
key={post.id} | |||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | |||||
onClick={onClick}> | |||||
<PrefetchLink | |||||
to={`/posts/${ post.id }`} | |||||
key={post.id} | |||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | |||||
onClick={onClick}> | |||||
<img src={post.thumbnail || post.thumbnailBase || undefined} | <img src={post.thumbnail || post.thumbnailBase || undefined} | ||||
alt={post.title || post.url} | alt={post.title || post.url} | ||||
title={post.title || post.url || undefined} | title={post.title || post.url || undefined} | ||||
loading={i < 12 ? 'eager' : 'lazy'} | loading={i < 12 ? 'eager' : 'lazy'} | ||||
decoding="async" | decoding="async" | ||||
className="object-cover w-full h-full"/> | className="object-cover w-full h-full"/> | ||||
</Link>))} | |||||
</PrefetchLink>))} | |||||
</div>)) satisfies FC<Props> | </div>)) satisfies FC<Props> |
@@ -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<HTMLAnchorElement> & { | |||||
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<HTMLAnchorElement>) => { | |||||
onMouseEnter?.(ev) | |||||
doPrefetch () | |||||
} | |||||
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { | |||||
onTouchStart?.(ev) | |||||
doPrefetch () | |||||
} | |||||
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | |||||
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 ( | |||||
<a href={to} | |||||
onMouseEnter={handleMouseEnter} | |||||
onTouchStart={handleTouchStart} | |||||
onClick={handleClick} | |||||
className={cn ('cursor-pointer', className)} | |||||
{...rest}/>) | |||||
}) satisfies FC<Props> |
@@ -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<OverlayStore> (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 ( | |||||
<div | |||||
role="progressbar" | |||||
aria-label="Loading" | |||||
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto"> | |||||
<div className="absolute inset-0 flex items-center justify-center"> | |||||
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm"> | |||||
Loading... | |||||
</div> | |||||
</div> | |||||
</div>) | |||||
}) satisfies FC |
@@ -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<Post> => { | |||||
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<void> => { | |||||
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) | |||||
} |
@@ -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<void> | |||||
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<void> => { | |||||
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) | |||||
} |
@@ -1,3 +1,4 @@ | |||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | |||||
import { createRoot } from 'react-dom/client' | import { createRoot } from 'react-dom/client' | ||||
import { HelmetProvider } from 'react-helmet-async' | import { HelmetProvider } from 'react-helmet-async' | ||||
@@ -6,7 +7,15 @@ import App from '@/App' | |||||
const helmetContext = { } | const helmetContext = { } | ||||
const client = new QueryClient ({ | |||||
defaultOptions: { | |||||
queries: { staleTime: 5 * 60 * 1000, | |||||
gcTime: 30 * 60 * 1000, | |||||
retry: 1 }}}) | |||||
createRoot (document.getElementById ('root')!).render ( | createRoot (document.getElementById ('root')!).render ( | ||||
<HelmetProvider context={helmetContext}> | <HelmetProvider context={helmetContext}> | ||||
<App/> | |||||
<QueryClientProvider client={client}> | |||||
<App/> | |||||
</QueryClientProvider> | |||||
</HelmetProvider>) | </HelmetProvider>) |
@@ -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 { useEffect, useState } from 'react' | ||||
import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
@@ -12,14 +11,15 @@ import TabGroup, { Tab } from '@/components/common/TabGroup' | |||||
import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
import { toast } from '@/components/ui/use-toast' | 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 { cn } from '@/lib/utils' | ||||
import NotFound from '@/pages/NotFound' | import NotFound from '@/pages/NotFound' | ||||
import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
import type { FC } from 'react' | import type { FC } from 'react' | ||||
import type { Post, User } from '@/types' | |||||
import type { User } from '@/types' | |||||
type Props = { user: User | null } | type Props = { user: User | null } | ||||
@@ -27,48 +27,50 @@ type Props = { user: User | null } | |||||
export default (({ user }: Props) => { | export default (({ user }: Props) => { | ||||
const { id } = useParams () | const { id } = useParams () | ||||
const [post, setPost] = useState<Post | null> (null) | |||||
const { data: post, isError: errorFlg, error } = useQuery ({ | |||||
enabled: Boolean (id), | |||||
queryKey: ['posts', String (id)], | |||||
queryFn: () => fetchPost (String (id)), | |||||
placeholderData: undefined }) | |||||
const qc = useQueryClient () | |||||
const [status, setStatus] = useState (200) | 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: ['posts', String (id)] }) | |||||
const prev = qc.getQueryData<any> (['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 (['posts', String (id)], ctx.prev) | |||||
toast ({ title: '失敗……', description: '通信に失敗しました……' }) | |||||
}, | |||||
onSuccess: () => { | |||||
qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) | |||||
qc.invalidateQueries ({ queryKey: ['related', String (id)] }) | |||||
} }) | |||||
useEffect (() => { | useEffect (() => { | ||||
setPost (null) | |||||
if (!(id)) | |||||
if (!(errorFlg)) | |||||
return | 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 () | |||||
const code = (error as any)?.response.status ?? (error as any)?.status | |||||
if (code) | |||||
setStatus (code) | |||||
}, [errorFlg, error]) | |||||
useEffect (() => { | |||||
setStatus (200) | |||||
}, [id]) | }, [id]) | ||||
switch (status) | switch (status) | ||||
@@ -90,15 +92,18 @@ export default (({ user }: Props) => { | |||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | ||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | ||||
</Helmet> | </Helmet> | ||||
<div className="hidden md:block"> | <div className="hidden md:block"> | ||||
<TagDetailSidebar post={post}/> | |||||
<TagDetailSidebar post={post ?? null}/> | |||||
</div> | </div> | ||||
<MainArea> | <MainArea> | ||||
{post | {post | ||||
? ( | ? ( | ||||
<> | <> | ||||
<PostEmbed post={post}/> | <PostEmbed post={post}/> | ||||
<Button onClick={changeViewedFlg} | |||||
<Button onClick={() => changeViewedFlg.mutate ()} | |||||
disabled={changeViewedFlg.isPending} | |||||
className={cn ('text-white', viewedClass)}> | className={cn ('text-white', viewedClass)}> | ||||
{post.viewed ? '閲覧済' : '未閲覧'} | {post.viewed ? '閲覧済' : '未閲覧'} | ||||
</Button> | </Button> | ||||
@@ -112,7 +117,10 @@ export default (({ user }: Props) => { | |||||
<Tab name="編輯"> | <Tab name="編輯"> | ||||
<PostEditForm post={post} | <PostEditForm post={post} | ||||
onSave={newPost => { | onSave={newPost => { | ||||
setPost (newPost) | |||||
qc.setQueryData (['posts', String (id)], | |||||
(prev: any) => newPost ?? prev) | |||||
qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) | |||||
qc.invalidateQueries ({ queryKey: ['related', String (id)] }) | |||||
toast ({ description: '更新しました.' }) | toast ({ description: '更新しました.' }) | ||||
}}/> | }}/> | ||||
</Tab>)} | </Tab>)} | ||||
@@ -120,8 +128,9 @@ export default (({ user }: Props) => { | |||||
</>) | </>) | ||||
: 'Loading...'} | : 'Loading...'} | ||||
</MainArea> | </MainArea> | ||||
<div className="md:hidden"> | <div className="md:hidden"> | ||||
<TagDetailSidebar post={post}/> | |||||
<TagDetailSidebar post={post ?? null}/> | |||||
</div> | </div> | ||||
</div>) | </div>) | ||||
}) satisfies FC<Props> | }) satisfies FC<Props> |