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