@@ -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" | |||
@@ -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" | |||
@@ -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<SetStateAction<User | null>> }) => { | |||
const location = useLocation () | |||
return ( | |||
<LayoutGroup id="gallery-shared"> | |||
<AnimatePresence mode="wait"> | |||
<Routes location={location} key={location.pathname}> | |||
<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> | |||
</AnimatePresence> | |||
</LayoutGroup>) | |||
} | |||
export default (() => { | |||
const [user, setUser] = useState<User | null> (null) | |||
const [status, setStatus] = useState (200) | |||
@@ -74,22 +108,7 @@ export default (() => { | |||
<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> | |||
<RouteTransitionWrapper user={user} setUser={setUser}/> | |||
</div> | |||
<Toaster/> | |||
</BrowserRouter>) | |||
@@ -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<HTMLElement>) => void } | |||
export default (({ posts, onClick }: Props) => ( | |||
<div className="flex flex-wrap gap-6 p-4"> | |||
{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}> | |||
<img src={post.thumbnail || post.thumbnailBase || undefined} | |||
alt={post.title || post.url} | |||
title={post.title || post.url || undefined} | |||
loading={i < 12 ? 'eager' : 'lazy'} | |||
decoding="async" | |||
className="object-cover w-full h-full"/> | |||
</Link>))} | |||
</div>)) satisfies FC<Props> | |||
export default (({ posts, onClick }: Props) => { | |||
const navigate = useNavigate () | |||
const qc = useQueryClient () | |||
const prefetch = (id: string) => qc.prefetchQuery ({ | |||
queryKey: ['post', id], | |||
queryFn: () => fetchPost (id) }) | |||
return ( | |||
<div className="flex flex-wrap gap-6 p-4"> | |||
{posts.map ((post, i) => { | |||
const id = String (post.id) | |||
const hRef = `/posts/${ id }` | |||
const cardRef = useRef<HTMLDivElement> (null) | |||
const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | |||
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 ( | |||
<Link to={hRef} | |||
key={id} | |||
className="w-40 h-40" | |||
onMouseEnter={() => prefetch (id)} | |||
onFocus={() => prefetch (id)} | |||
onClick={handleClick}> | |||
<motion.div | |||
ref={cardRef} | |||
layoutId={`page-${ id }`} | |||
className="w-full h-full overflow-hidden rounded-xl shadow | |||
transform-gpu will-change-transform" | |||
whileHover={{ scale: 1.02 }} | |||
onLayoutAnimationStart={() => { | |||
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 }}> | |||
<img src={post.thumbnail || post.thumbnailBase || undefined} | |||
alt={post.title || post.url} | |||
title={post.title || post.url || undefined} | |||
loading={i < 12 ? 'eager' : 'lazy'} | |||
decoding="async" | |||
className="object-cover w-full h-full"/> | |||
</motion.div> | |||
</Link>) | |||
})} | |||
</div>) | |||
}) satisfies FC<Props> |
@@ -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) => ( | |||
<main className="flex-1 overflow-y-auto p-4"> | |||
export default (({ children, className }: Props) => ( | |||
<main className={cn ('flex-1 overflow-y-auto p-4', className)}> | |||
{children} | |||
</main>) | |||
</main>)) satisfies FC<Props> |
@@ -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<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) | |||
} |
@@ -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 ( | |||
<HelmetProvider context={helmetContext}> | |||
<App/> | |||
<QueryClientProvider client={client}> | |||
<App/> | |||
</QueryClientProvider> | |||
</HelmetProvider>) |
@@ -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<Post | null> (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<any> (['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) => { | |||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||
</Helmet> | |||
<div className="hidden md:block"> | |||
<TagDetailSidebar post={post}/> | |||
<TagDetailSidebar post={post ?? null}/> | |||
</div> | |||
<MainArea> | |||
<MainArea className="relative"> | |||
<motion.div | |||
layoutId={`page-${ String (id) }`} | |||
initial={{ clipPath: 'inset(0% 0% 0% 0%)' }} | |||
animate={{ clipPath: 'inset(0% 0% 0% 0% round 0px)', opacity: 0 }} | |||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }} | |||
className="absolute overflow-hidden transform-gpu will-change-transform | |||
inset-0 pointer-events-none z-10 w-[640px] h-[360px]"> | |||
<img src={post?.thumbnailBase || post?.thumbnail} | |||
alt={post?.url}/> | |||
</motion.div> | |||
{post | |||
? ( | |||
<> | |||
<PostEmbed post={post}/> | |||
<Button onClick={changeViewedFlg} | |||
<Button onClick={() => changeViewedFlg.mutate ()} | |||
disabled={changeViewedFlg.isPending} | |||
className={cn ('text-white', viewedClass)}> | |||
{post.viewed ? '閲覧済' : '未閲覧'} | |||
</Button> | |||
@@ -112,7 +124,10 @@ export default (({ user }: Props) => { | |||
<Tab name="編輯"> | |||
<PostEditForm post={post} | |||
onSave={newPost => { | |||
setPost (newPost) | |||
qc.setQueryData (['post', String (id)], | |||
(prev: any) => newPost ?? prev) | |||
qc.invalidateQueries ({ queryKey: ['posts'] }) | |||
qc.invalidateQueries ({ queryKey: ['related', String (id)] }) | |||
toast ({ description: '更新しました.' }) | |||
}}/> | |||
</Tab>)} | |||
@@ -120,8 +135,9 @@ export default (({ user }: Props) => { | |||
</>) | |||
: 'Loading...'} | |||
</MainArea> | |||
<div className="md:hidden"> | |||
<TagDetailSidebar post={post}/> | |||
<TagDetailSidebar post={post ?? null}/> | |||
</div> | |||
</div>) | |||
}) satisfies FC<Props> |