コミットを比較
1 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 8ef94876b0 |
生成ファイル
+78
-8
@@ -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",
|
"version": "6.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||||
"integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==",
|
"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",
|
"version": "6.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||||
"integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==",
|
"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
@@ -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
@@ -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>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
|
||||||
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/>
|
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
||||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
|
||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
|
||||||
<Route path="*" element={<NotFound/>}/>
|
|
||||||
</Routes>
|
|
||||||
</div>
|
</div>
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</BrowserRouter>)
|
</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'
|
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) => (
|
export default (({ posts, onClick }: Props) => {
|
||||||
<div className="flex flex-wrap gap-6 p-4">
|
const navigate = useNavigate ()
|
||||||
{posts.map ((post, i) => (
|
|
||||||
<Link to={`/posts/${ post.id }`}
|
const qc = useQueryClient ()
|
||||||
key={post.id}
|
|
||||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
const prefetch = (id: string) => qc.prefetchQuery ({
|
||||||
onClick={onClick}>
|
queryKey: ['post', id],
|
||||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
queryFn: () => fetchPost (id) })
|
||||||
alt={post.title || post.url}
|
|
||||||
title={post.title || post.url || undefined}
|
return (
|
||||||
loading={i < 12 ? 'eager' : 'lazy'}
|
<div className="flex flex-wrap gap-6 p-4">
|
||||||
decoding="async"
|
{posts.map ((post, i) => {
|
||||||
className="object-cover w-full h-full"/>
|
const id = String (post.id)
|
||||||
</Link>))}
|
const hRef = `/posts/${ id }`
|
||||||
</div>)) satisfies FC<Props>
|
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) => (
|
export default (({ children, className }: Props) => (
|
||||||
<main className="flex-1 overflow-y-auto p-4">
|
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
|
||||||
{children}
|
{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)
|
||||||
|
}
|
||||||
+10
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
||||||
@@ -6,7 +7,15 @@ import App from '@/App'
|
|||||||
|
|
||||||
const helmetContext = { }
|
const helmetContext = { }
|
||||||
|
|
||||||
|
const client = new QueryClient ({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
retry: 1 } } })
|
||||||
|
|
||||||
createRoot (document.getElementById ('root')!).render (
|
createRoot (document.getElementById ('root')!).render (
|
||||||
<HelmetProvider context={helmetContext}>
|
<HelmetProvider context={helmetContext}>
|
||||||
<App/>
|
<QueryClientProvider client={client}>
|
||||||
|
<App/>
|
||||||
|
</QueryClientProvider>
|
||||||
</HelmetProvider>)
|
</HelmetProvider>)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import toCamel from 'camelcase-keys'
|
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 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)
|
||||||
toast ({ title: '失敗……', description: '通信に失敗しました……' })
|
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 (!(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 +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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする