コミットを比較

...

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 7df51fb34b #140 ぼちぼち 2025-12-07 17:21:58 +09:00
みてるぞ 4e00ec40ab Merge remote-tracking branch 'origin/main' into feature/140 2025-12-07 13:09:49 +09:00
みてるぞ 214c91e3bf #140 2025-10-05 17:05:05 +09:00
みてるぞ 5cbe21b5d7 #140 2025-10-05 12:52:43 +09:00
みてるぞ d5d7e0e22b #140 2025-10-05 03:15:46 +09:00
13個のファイルの変更427行の追加94行の削除
生成ファイル
+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",
+4 -1
ファイルの表示
@@ -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",
+14 -3
ファイルの表示
@@ -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,6 +79,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}/>
@@ -78,7 +88,7 @@ export default (() => {
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
@@ -92,5 +102,6 @@ export default (() => {
</Routes> </Routes>
</div> </div>
<Toaster/> <Toaster/>
</BrowserRouter>) </BrowserRouter>
</>)
}) satisfies FC }) satisfies FC
+4 -3
ファイルの表示
@@ -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,8 @@ 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 +22,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>
+87
ファイルの表示
@@ -0,0 +1,87 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'
import { createPath, 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'
import type { To } from 'react-router-dom'
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: To
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 (() => {
const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, 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={typeof to === 'string' ? to : createPath (to)}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onClick={handleClick}
className={cn ('cursor-pointer', className)}
{...rest}/>)
}) satisfies FC<Props>
+46
ファイルの表示
@@ -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
+13 -2
ファイルの表示
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -9,7 +10,8 @@ import type { Tag } from '@/types'
type CommonProps = { tag: Tag type CommonProps = { tag: Tag
withWiki?: boolean withWiki?: boolean
withCount?: boolean } withCount?: boolean
prefetch?: boolean }
type PropsWithLink = type PropsWithLink =
CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>> CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>>
@@ -24,6 +26,7 @@ export default (({ tag,
linkFlg = true, linkFlg = true,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
prefetch = false,
...props }: Props) => { ...props }: Props) => {
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
@@ -44,7 +47,15 @@ export default (({ tag,
</span>)} </span>)}
{linkFlg {linkFlg
? ( ? (
<Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>
: <Link
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass} className={linkClass}
{...props}> {...props}>
{tag.name} {tag.name}
+7 -6
ファイルの表示
@@ -10,16 +10,17 @@ import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC, MouseEvent } from 'react'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
type TagByCategory = Record<string, Tag[]> type TagByCategory = Record<string, Tag[]>
type Props = { posts: Post[] } type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }
export default (({ posts }: Props) => { export default (({ posts, onClick }: Props) => {
const navigate = useNavigate () const navigate = useNavigate ()
const [tagsVsbl, setTagsVsbl] = useState (false) const [tagsVsbl, setTagsVsbl] = useState (false)
@@ -64,11 +65,11 @@ export default (({ posts }: Props) => {
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}> <div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<ul> <ul>
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ?
tags[cat].map (tag => ( tags[cat].map (tag => (
<li key={tag.id} className="mb-1"> <li key={tag.id} className="mb-1">
<TagLink tag={tag}/> <TagLink tag={tag} prefetch onClick={onClick}/>
</li>))) : [])} </li>)) : [])}
</ul> </ul>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
{posts.length > 0 && ( {posts.length > 0 && (
+37
ファイルの表示
@@ -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)
}
+45
ファイルの表示
@@ -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)
}
+9
ファイルの表示
@@ -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>)
+52 -43
ファイルの表示
@@ -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,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 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: ['posts', String (id)] })
await axios.post (url, { }, opt) const prev = qc.getQueryData<any> (['posts', String (id)])
qc.setQueryData (['posts', 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 (['posts', String (id)], ctx.prev)
toast ({ title: '失敗……', description: '通信に失敗しました……' }) toast ({ title: '失敗……', description: '通信に失敗しました……' })
} },
} onSuccess: () => {
qc.invalidateQueries ({ queryKey: ['posts', 'index'] })
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) useEffect (() => {
} setStatus (200)
catch (err)
{
if (axios.isAxiosError (err))
setStatus (err.status ?? 200)
}
}
fetchPost ()
}, [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>
+12 -7
ファイルの表示
@@ -10,6 +10,7 @@ import WikiBody from '@/components/WikiBody'
import TabGroup, { Tab } from '@/components/common/TabGroup' import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config' import { API_BASE_URL, SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import type { Post, WikiPage } from '@/types' import type { Post, WikiPage } from '@/types'
@@ -28,13 +29,11 @@ export default () => {
const loadMore = async (withCursor: boolean) => { const loadMore = async (withCursor: boolean) => {
setLoading (true) setLoading (true)
const res = await axios.get (`${ API_BASE_URL }/posts`, { const data = await fetchPosts ({
params: { tags: tags.join (' '), tags: tags.join (' '),
match: anyFlg ? 'any' : 'all', match: anyFlg ? 'any' : 'all',
limit: '20', limit: 20,
...(withCursor && { cursor }) } }) ...(withCursor && { cursor }) })
const data = toCamel (res.data as any, { deep: true }) as { posts: Post[]
nextCursor: string }
setPosts (posts => ( setPosts (posts => (
[...((new Map ([...(withCursor ? posts : []), ...data.posts] [...((new Map ([...(withCursor ? posts : []), ...data.posts]
.map (post => [post.id, post]))) .map (post => [post.id, post])))
@@ -111,7 +110,13 @@ export default () => {
</title> </title>
</Helmet> </Helmet>
<TagSidebar posts={posts.slice (0, 20)}/> <TagSidebar posts={posts.slice (0, 20)} onClick={() => {
const statesToSave = {
posts, cursor,
scroll: containerRef.current?.scrollTop ?? 0 }
sessionStorage.setItem (`posts:${ tagsQuery }`,
JSON.stringify (statesToSave))
}}/>
<MainArea> <MainArea>
<TabGroup> <TabGroup>