Compare commits

...

1 Commits

Author SHA1 Message Date
  みてるぞ 8ef94876b0 #139 アニメーション 1 week ago
8 changed files with 298 additions and 95 deletions
Unified View
  1. +78
    -8
      frontend/package-lock.json
  2. +3
    -1
      frontend/package.json
  3. +37
    -18
      frontend/src/App.tsx
  4. +76
    -16
      frontend/src/components/PostList.tsx
  5. +9
    -5
      frontend/src/components/layout/MainArea.tsx
  6. +23
    -0
      frontend/src/lib/posts.ts
  7. +10
    -1
      frontend/src/main.tsx
  8. +62
    -46
      frontend/src/pages/posts/PostDetailPage.tsx

+ 78
- 8
frontend/package-lock.json View File

@@ -13,10 +13,12 @@
"@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",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.22",
"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",
@@ -25,7 +27,7 @@
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4", "react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.30.1",
"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"
@@ -1908,6 +1910,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",
@@ -3573,6 +3601,33 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5216,6 +5271,21 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5891,9 +5961,9 @@
} }
}, },
"node_modules/react-router": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.0" "@remix-run/router": "1.23.0"
@@ -5906,13 +5976,13 @@
} }
}, },
"node_modules/react-router-dom": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.0", "@remix-run/router": "1.23.0",
"react-router": "6.30.0"
"react-router": "6.30.1"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"


+ 3
- 1
frontend/package.json View File

@@ -15,10 +15,12 @@
"@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",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.22",
"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",
@@ -27,7 +29,7 @@
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4", "react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.30.1",
"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"


+ 37
- 18
frontend/src/App.tsx View File

@@ -1,7 +1,12 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys' import toCamel from 'camelcase-keys'
import { AnimatePresence, LayoutGroup } from 'framer-motion'
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 TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
@@ -20,11 +25,40 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage'


import type { FC } from 'react'
import type { Dispatch, FC, SetStateAction } from 'react'


import type { User } from '@/types' 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 (() => { 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)
@@ -74,22 +108,7 @@ export default (() => {
<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}/>
<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> </div>
<Toaster/> <Toaster/>
</BrowserRouter>) </BrowserRouter>)


+ 76
- 16
frontend/src/components/PostList.tsx View File

@@ -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' import type { FC, MouseEvent } from 'react'


@@ -8,18 +13,73 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void } 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>

+ 9
- 5
frontend/src/components/layout/MainArea.tsx View File

@@ -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} {children}
</main>)
</main>)) satisfies FC<Props>

+ 23
- 0
frontend/src/lib/posts.ts View File

@@ -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)
}

+ 10
- 1
frontend/src/main.tsx View File

@@ -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>)

+ 62
- 46
frontend/src/pages/posts/PostDetailPage.tsx View File

@@ -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 { 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 +12,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 +28,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 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 [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 (() => { 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 ()
}, [id])
const code = (error as any)?.response.status ?? (error as any)?.status
if (code)
setStatus (code)
}, [errorFlg, error])


switch (status) switch (status)
{ {
@@ -90,15 +88,29 @@ 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 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 {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 +124,10 @@ export default (({ user }: Props) => {
<Tab name="編輯"> <Tab name="編輯">
<PostEditForm post={post} <PostEditForm post={post}
onSave={newPost => { 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: '更新しました.' }) toast ({ description: '更新しました.' })
}}/> }}/>
</Tab>)} </Tab>)}
@@ -120,8 +135,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>

Loading…
Cancel
Save