バグ修正(#253) #254
Generated
+69
-1
@@ -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",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 } 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'
|
||||||
@@ -71,6 +72,8 @@ export default (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<RouteBlockerOverlay/>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="flex flex-col h-screen w-screen">
|
<div className="flex flex-col h-screen w-screen">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
@@ -92,5 +95,6 @@ export default (() => {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</BrowserRouter>)
|
</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,7 +11,7 @@ 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 }`}
|
<PrefetchLink to={`/posts/${ post.id }`}
|
||||||
key={post.id}
|
key={post.id}
|
||||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
||||||
onClick={onClick}>
|
onClick={onClick}>
|
||||||
@@ -21,5 +21,5 @@ export default (({ posts, onClick }: Props) => (
|
|||||||
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}>
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
<App/>
|
<App/>
|
||||||
|
</QueryClientProvider>
|
||||||
</HelmetProvider>)
|
</HelmetProvider>)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import axios from 'axios'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import toCamel from 'camelcase-keys'
|
|
||||||
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,49 +27,46 @@ 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)) })
|
||||||
|
|
||||||
|
const qc = useQueryClient ()
|
||||||
|
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
|
|
||||||
const changeViewedFlg = async () => {
|
const changeViewedFlg = useMutation ({
|
||||||
const url = `${ API_BASE_URL }/posts/${ id }/viewed`
|
mutationFn: async () => {
|
||||||
const opt = { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }
|
const next = !(post!.viewed)
|
||||||
try
|
await toggleViewedFlg (id!, next)
|
||||||
{
|
return next
|
||||||
if (post!.viewed)
|
},
|
||||||
await axios.delete (url, opt)
|
onMutate: async () => {
|
||||||
else
|
await qc.cancelQueries ({ queryKey: ['post', String (id)] })
|
||||||
await axios.post (url, { }, opt)
|
const prev = qc.getQueryData<any> (['post', String (id)])
|
||||||
|
qc.setQueryData (['post', String (id)],
|
||||||
// 通信に成功したら “閲覧済” をトグル
|
(cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur)
|
||||||
setPost (post => ({ ...post!, viewed: !(post!.viewed) }))
|
return { prev }
|
||||||
}
|
},
|
||||||
catch
|
onError: (...[, , ctx]) => {
|
||||||
{
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData (['post', String (id)], ctx.prev)
|
||||||
toast ({ title: '失敗……', description: '通信に失敗しました……' })
|
toast ({ title: '失敗……', description: '通信に失敗しました……' })
|
||||||
}
|
},
|
||||||
}
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries ({ queryKey: ['posts'] })
|
||||||
|
qc.invalidateQueries ({ queryKey: ['related', String (id)] })
|
||||||
|
} })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setPost (null)
|
if (!(errorFlg))
|
||||||
|
|
||||||
if (!(id))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
const fetchPost = async () => {
|
const code = (error as any)?.response.status ?? (error as any)?.status
|
||||||
try
|
if (code)
|
||||||
{
|
setStatus (code)
|
||||||
const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: {
|
}, [errorFlg, error])
|
||||||
'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])
|
|
||||||
|
|
||||||
switch (status)
|
switch (status)
|
||||||
{
|
{
|
||||||
@@ -90,15 +87,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 +112,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 +123,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user