#139 #139 #139 #139 #139 Merge branch 'feature/140' into feature/139 Merge remote-tracking branch 'origin/main' into feature/139 #140 Merge remote-tracking branch 'origin/main' into feature/140 Merge remote-tracking branch 'origin/main' into feature/140 #140 ぼちぼち Merge remote-tracking branch 'origin/main' into feature/140 #140 #140 #140 #139 アニメーション Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/252pull/255/head
| @@ -16,6 +16,7 @@ | |||
| "@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", | |||
| @@ -24,16 +25,18 @@ | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| "path-to-regexp": "^8.3.0", | |||
| "react": "^19.1.0", | |||
| "react-dom": "^19.1.0", | |||
| "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", | |||
| "unist-util-visit-parents": "^6.0.1" | |||
| "unist-util-visit-parents": "^6.0.1", | |||
| "zustand": "^5.0.8" | |||
| }, | |||
| "devDependencies": { | |||
| "@eslint/js": "^9.25.0", | |||
| @@ -1966,6 +1969,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", | |||
| @@ -5552,6 +5581,16 @@ | |||
| "dev": true, | |||
| "license": "ISC" | |||
| }, | |||
| "node_modules/path-to-regexp": { | |||
| "version": "8.3.0", | |||
| "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", | |||
| "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", | |||
| "license": "MIT", | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/express" | |||
| } | |||
| }, | |||
| "node_modules/picocolors": { | |||
| "version": "1.1.1", | |||
| "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |||
| @@ -5991,9 +6030,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" | |||
| @@ -6006,13 +6045,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" | |||
| @@ -7286,6 +7325,35 @@ | |||
| "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/zustand": { | |||
| "version": "5.0.8", | |||
| "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", | |||
| "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", | |||
| "license": "MIT", | |||
| "engines": { | |||
| "node": ">=12.20.0" | |||
| }, | |||
| "peerDependencies": { | |||
| "@types/react": ">=18.0.0", | |||
| "immer": ">=9.0.6", | |||
| "react": ">=18.0.0", | |||
| "use-sync-external-store": ">=1.2.0" | |||
| }, | |||
| "peerDependenciesMeta": { | |||
| "@types/react": { | |||
| "optional": true | |||
| }, | |||
| "immer": { | |||
| "optional": true | |||
| }, | |||
| "react": { | |||
| "optional": true | |||
| }, | |||
| "use-sync-external-store": { | |||
| "optional": true | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/zwitch": { | |||
| "version": "2.0.4", | |||
| "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", | |||
| @@ -18,6 +18,7 @@ | |||
| "@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", | |||
| @@ -26,15 +27,17 @@ | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| "path-to-regexp": "^8.3.0", | |||
| "react": "^19.1.0", | |||
| "react-dom": "^19.1.0", | |||
| "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", | |||
| "zustand": "^5.0.8", | |||
| "unist-util-visit-parents": "^6.0.1" | |||
| }, | |||
| "devDependencies": { | |||
| @@ -1,8 +1,14 @@ | |||
| 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 RouteBlockerOverlay from '@/components/RouteBlockerOverlay' | |||
| import TopNav from '@/components/TopNav' | |||
| import { Toaster } from '@/components/ui/toaster' | |||
| import { API_BASE_URL } from '@/config' | |||
| @@ -21,11 +27,48 @@ 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={<PostDetailRoute user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <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>) | |||
| } | |||
| const PostDetailRoute = ({ user }: { user: User | null }) => { | |||
| const location = useLocation () | |||
| const key = location.pathname | |||
| return <PostDetailPage key={key} user={user}/> | |||
| } | |||
| export default (() => { | |||
| const [user, setUser] = useState<User | null> (null) | |||
| const [status, setStatus] = useState (200) | |||
| @@ -72,27 +115,14 @@ export default (() => { | |||
| } | |||
| return ( | |||
| <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="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <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> | |||
| <Toaster/> | |||
| </BrowserRouter>) | |||
| <> | |||
| <RouteBlockerOverlay/> | |||
| <BrowserRouter> | |||
| <div className="flex flex-col h-screen w-screen"> | |||
| <TopNav user={user}/> | |||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | |||
| </div> | |||
| <Toaster/> | |||
| </BrowserRouter> | |||
| </>) | |||
| }) satisfies FC | |||
| @@ -1,4 +1,9 @@ | |||
| import { Link } from 'react-router-dom' | |||
| import { motion } from 'framer-motion' | |||
| import { useRef } from 'react' | |||
| import { useLocation } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' | |||
| import type { FC, MouseEvent } from 'react' | |||
| @@ -8,18 +13,61 @@ 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 location = useLocation () | |||
| const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | |||
| const cardRef = useRef<HTMLDivElement> (null) | |||
| return ( | |||
| <> | |||
| <div className="flex flex-wrap gap-6 p-4"> | |||
| {posts.map ((post, i) => { | |||
| const id2 = `page-${ post.id }` | |||
| const layoutId = id2 | |||
| return ( | |||
| <PrefetchLink | |||
| to={`/posts/${ post.id }`} | |||
| key={post.id} | |||
| className="w-40 h-40" | |||
| state={{ sharedId: `page-${ post.id }` }} | |||
| onClick={e => { | |||
| const sharedId = `page-${ post.id }` | |||
| setForLocationKey (location.key, sharedId) | |||
| onClick?.(e) | |||
| }}> | |||
| <motion.div | |||
| ref={cardRef} | |||
| layoutId={layoutId} | |||
| 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> | |||
| </PrefetchLink>) | |||
| })} | |||
| </div> | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,107 @@ | |||
| import { useQueryClient } from '@tanstack/react-query' | |||
| import { forwardRef, useMemo } from 'react' | |||
| import { flushSync } from 'react-dom' | |||
| import { createPath, useNavigate } from 'react-router-dom' | |||
| import { useOverlayStore } from '@/components/RouteBlockerOverlay' | |||
| import { prefetchForURL } from '@/lib/prefetchers' | |||
| import { cn } from '@/lib/utils' | |||
| import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' | |||
| import type { To } from 'react-router-dom' | |||
| type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { | |||
| to: To | |||
| state?: Record<string, string> | |||
| replace?: boolean | |||
| className?: string | |||
| cancelOnError?: boolean } | |||
| export default forwardRef<HTMLAnchorElement, Props> (({ | |||
| to, | |||
| replace, | |||
| className, | |||
| state, | |||
| onMouseEnter, | |||
| onTouchStart, | |||
| onClick, | |||
| cancelOnError = false, | |||
| ...rest }, ref) => { | |||
| if ('onClick' in rest) | |||
| delete rest['onClick'] | |||
| const navigate = useNavigate () | |||
| const qc = useQueryClient () | |||
| const url = useMemo (() => { | |||
| const path = (typeof to === 'string') ? to : createPath (to) | |||
| return (new URL (path, location.origin)).toString () | |||
| }, [to]) | |||
| const setOverlay = useOverlayStore (s => s.setActive) | |||
| const doPrefetch = async () => { | |||
| try | |||
| { | |||
| await prefetchForURL (qc, url) | |||
| return true | |||
| } | |||
| catch (e) | |||
| { | |||
| console.error ('データ取得エラー', e) | |||
| return false | |||
| } | |||
| } | |||
| const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => { | |||
| onMouseEnter?.(ev) | |||
| await doPrefetch () | |||
| } | |||
| const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { | |||
| onTouchStart?.(ev) | |||
| await doPrefetch () | |||
| } | |||
| const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | |||
| try | |||
| { | |||
| onClick?.(ev) | |||
| if (ev.defaultPrevented | |||
| || ev.metaKey | |||
| || ev.ctrlKey | |||
| || ev.shiftKey | |||
| || ev.altKey) | |||
| return | |||
| ev.preventDefault () | |||
| flushSync (() => { | |||
| setOverlay (true) | |||
| }) | |||
| const ok = await doPrefetch () | |||
| flushSync (() => { | |||
| setOverlay (false) | |||
| }) | |||
| if (!(ok) && cancelOnError) | |||
| return | |||
| navigate (to, { replace, ...(state && { state }) }) | |||
| } | |||
| catch (ex) | |||
| { | |||
| console.log (ex) | |||
| ev.preventDefault () | |||
| } | |||
| } | |||
| return ( | |||
| <a ref={ref} | |||
| href={typeof to === 'string' ? to : createPath (to)} | |||
| onMouseEnter={handleMouseEnter} | |||
| onTouchStart={handleTouchStart} | |||
| onClick={handleClick} | |||
| className={cn ('cursor-pointer', className)} | |||
| {...rest}/>) | |||
| }) | |||
| @@ -0,0 +1,46 @@ | |||
| import { useEffect } from 'react' | |||
| import { create } from 'zustand' | |||
| import type { FC } from 'react' | |||
| type OverlayStore = { | |||
| active: boolean | |||
| setActive: (v: boolean) => void } | |||
| export const useOverlayStore = create<OverlayStore> (set => ({ | |||
| active: false, | |||
| setActive: v => set ({ active: v }) })) | |||
| export default (() => { | |||
| const active = useOverlayStore (s => s.active) | |||
| useEffect (() => { | |||
| if (active) | |||
| { | |||
| document.body.style.overflow = 'hidden' | |||
| document.body.setAttribute ('aria-busy', 'true') | |||
| } | |||
| else | |||
| { | |||
| document.body.style.overflow = '' | |||
| document.body.removeAttribute ('aria-busy') | |||
| } | |||
| }, [active]) | |||
| if (!(active)) | |||
| return null | |||
| return ( | |||
| <div | |||
| role="progressbar" | |||
| aria-label="Loading" | |||
| className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto"> | |||
| <div className="absolute inset-0 flex items-center justify-center"> | |||
| <div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm"> | |||
| Loading... | |||
| </div> | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC | |||
| @@ -2,6 +2,7 @@ import axios from 'axios' | |||
| import { useEffect, useState } from 'react' | |||
| import { Link } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | |||
| import { cn } from '@/lib/utils' | |||
| @@ -13,7 +14,8 @@ import type { Tag } from '@/types' | |||
| type CommonProps = { tag: Tag | |||
| nestLevel?: number | |||
| withWiki?: boolean | |||
| withCount?: boolean } | |||
| withCount?: boolean | |||
| prefetch?: boolean } | |||
| type PropsWithLink = | |||
| CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>> | |||
| @@ -29,6 +31,7 @@ export default (({ tag, | |||
| linkFlg = true, | |||
| withWiki = true, | |||
| withCount = true, | |||
| prefetch = false, | |||
| ...props }: Props) => { | |||
| const [havingWiki, setHavingWiki] = useState (true) | |||
| @@ -100,11 +103,19 @@ export default (({ tag, | |||
| </>)} | |||
| {linkFlg | |||
| ? ( | |||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||
| prefetch | |||
| ? <PrefetchLink | |||
| to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||
| className={linkClass} | |||
| {...props}> | |||
| {tag.name} | |||
| </Link>) | |||
| {tag.name} | |||
| </PrefetchLink> | |||
| : <Link | |||
| to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||
| className={linkClass} | |||
| {...props}> | |||
| {tag.name} | |||
| </Link>) | |||
| : ( | |||
| <span className={spanClass} | |||
| {...props}> | |||
| @@ -10,16 +10,17 @@ import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { CATEGORIES } from '@/consts' | |||
| import type { FC } from 'react' | |||
| import type { FC, MouseEvent } from 'react' | |||
| import type { Post, Tag } from '@/types' | |||
| type TagByCategory = Record<string, Tag[]> | |||
| type Props = { posts: Post[] } | |||
| type Props = { posts: Post[] | |||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | |||
| export default (({ posts }: Props) => { | |||
| export default (({ posts, onClick }: Props) => { | |||
| const navigate = useNavigate () | |||
| const [tagsVsbl, setTagsVsbl] = useState (false) | |||
| @@ -65,7 +66,7 @@ export default (({ posts }: Props) => { | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag}/> | |||
| <TagLink tag={tag} prefetch onClick={onClick}/> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| @@ -1,18 +1,18 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | |||
| import { Link, useLocation } from 'react-router-dom' | |||
| import { useLocation } from 'react-router-dom' | |||
| import Separator from '@/components/MenuSeparator' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import TopNavUser from '@/components/TopNavUser' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | |||
| import { fetchTagByName } from '@/lib/tags' | |||
| import { cn } from '@/lib/utils' | |||
| import { fetchWikiPage } from '@/lib/wiki' | |||
| import type { FC } from 'react' | |||
| import type { FC, MouseEvent } from 'react' | |||
| import type { Menu, Tag, User, WikiPage } from '@/types' | |||
| import type { Menu, User } from '@/types' | |||
| type Props = { user: User | null } | |||
| @@ -120,11 +120,8 @@ export default (({ user }: Props) => { | |||
| const fetchPostCount = async () => { | |||
| try | |||
| { | |||
| const pageRes = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | |||
| const wikiPage = toCamel (pageRes.data as any, { deep: true }) as WikiPage | |||
| const tagRes = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | |||
| const tag = toCamel (tagRes.data as any, { deep: true }) as Tag | |||
| const wikiPage = await fetchWikiPage (String (wikiId ?? '')) | |||
| const tag = await fetchTagByName (wikiPage.title) | |||
| setPostCount (tag.postCount) | |||
| } | |||
| @@ -141,11 +138,15 @@ export default (({ user }: Props) => { | |||
| <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | |||
| bg-yellow-200 dark:bg-red-975 md:bg-yellow-50"> | |||
| <div className="flex items-center gap-2 h-full"> | |||
| <Link to="/" | |||
| className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 | |||
| dark:text-pink-300 dark:hover:text-pink-100"> | |||
| <PrefetchLink | |||
| to="/posts" | |||
| className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 | |||
| dark:text-pink-300 dark:hover:text-pink-100" | |||
| onClick={() => { | |||
| scroll (0, 0) | |||
| }}> | |||
| ぼざクリ タグ広場 | |||
| </Link> | |||
| </PrefetchLink> | |||
| <div ref={navRef} className="relative hidden md:flex h-full items-center"> | |||
| <div aria-hidden | |||
| @@ -157,15 +158,16 @@ export default (({ user }: Props) => { | |||
| opacity: hl.visible ? 1 : 0 }}/> | |||
| {menu.map ((item, i) => ( | |||
| <Link key={i} | |||
| to={item.to} | |||
| ref={el => { | |||
| itemsRef.current[i] = el | |||
| }} | |||
| className={cn ('relative z-10 flex h-full items-center px-5', | |||
| (i === openItemIdx) && 'font-bold')}> | |||
| <PrefetchLink | |||
| key={i} | |||
| to={item.to} | |||
| ref={(el: (HTMLAnchorElement | null)) => { | |||
| itemsRef.current[i] = el | |||
| }} | |||
| className={cn ('relative z-10 flex h-full items-center px-5', | |||
| (i === openItemIdx) && 'font-bold')}> | |||
| {item.name} | |||
| </Link>))} | |||
| </PrefetchLink>))} | |||
| </div> | |||
| </div> | |||
| @@ -203,11 +205,12 @@ export default (({ user }: Props) => { | |||
| 'component' in item | |||
| ? <Fragment key={`c-${ i }`}>{item.component}</Fragment> | |||
| : ( | |||
| <Link key={`l-${ i }`} | |||
| to={item.to} | |||
| className="h-full flex items-center px-3"> | |||
| <PrefetchLink | |||
| key={`l-${ i }`} | |||
| to={item.to} | |||
| className="h-full flex items-center px-3"> | |||
| {item.name} | |||
| </Link>)))} | |||
| </PrefetchLink>)))} | |||
| </motion.div> | |||
| </AnimatePresence> | |||
| </div> | |||
| @@ -229,19 +232,20 @@ export default (({ user }: Props) => { | |||
| <Separator/> | |||
| {menu.map ((item, i) => ( | |||
| <Fragment key={i}> | |||
| <Link to={i === openItemIdx ? item.to : '#'} | |||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||
| ((i === openItemIdx) | |||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||
| onClick={ev => { | |||
| if (i !== openItemIdx) | |||
| { | |||
| ev.preventDefault () | |||
| setOpenItemIdx (i) | |||
| } | |||
| }}> | |||
| <PrefetchLink | |||
| to={i === openItemIdx ? item.to : '#'} | |||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||
| ((i === openItemIdx) | |||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||
| onClick={(ev: MouseEvent<HTMLAnchorElement>) => { | |||
| if (i !== openItemIdx) | |||
| { | |||
| ev.preventDefault () | |||
| setOpenItemIdx (i) | |||
| } | |||
| }}> | |||
| {item.name} | |||
| </Link> | |||
| </PrefetchLink> | |||
| <AnimatePresence initial={false}> | |||
| {i === openItemIdx && ( | |||
| @@ -267,11 +271,12 @@ export default (({ user }: Props) => { | |||
| {subItem.component} | |||
| </Fragment>) | |||
| : ( | |||
| <Link key={`sp-l-${ i }-${ j }`} | |||
| to={subItem.to} | |||
| className="w-full min-h-[36px] flex items-center pl-12"> | |||
| <PrefetchLink | |||
| key={`sp-l-${ i }-${ j }`} | |||
| to={subItem.to} | |||
| className="w-full min-h-[36px] flex items-center pl-12"> | |||
| {subItem.name} | |||
| </Link>)))} | |||
| </PrefetchLink>)))} | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| </Fragment>))} | |||
| @@ -1,4 +1,6 @@ | |||
| import { Link, useLocation } from 'react-router-dom' | |||
| import { useLocation } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import type { FC } from 'react' | |||
| @@ -61,7 +63,7 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | |||
| <div className="flex items-center gap-2"> | |||
| {(page > 1) | |||
| ? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link> | |||
| ? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ"><</PrefetchLink> | |||
| : <span aria-hidden><</span>} | |||
| {pages.map ((p, idx) => ( | |||
| @@ -69,10 +71,10 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||
| ? <span key={`dots-${ idx }`}>…</span> | |||
| : ((p === page) | |||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | |||
| : <Link key={p} to={buildTo (p)}>{p}</Link>)))} | |||
| : <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))} | |||
| {(page < totalPages) | |||
| ? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link> | |||
| ? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">></PrefetchLink> | |||
| : <span aria-hidden>></span>} | |||
| </div> | |||
| </nav>) | |||
| @@ -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,65 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { API_BASE_URL } from '@/config' | |||
| type Opt = { | |||
| params?: Record<string, unknown> | |||
| headers?: Record<string, string> } | |||
| const client = axios.create ({ baseURL: API_BASE_URL }) | |||
| const withUserCode = (opt?: Opt): Opt => ({ | |||
| ...opt, | |||
| headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', | |||
| ...(opt?.headers ?? { }) } }) | |||
| const apiP = async <T> ( | |||
| method: 'post' | 'put' | 'patch', | |||
| path: string, | |||
| body?: unknown, | |||
| opt?: Opt, | |||
| ): Promise<T> => { | |||
| const res = await client[method] (path, body ?? { }, withUserCode (opt)) | |||
| return toCamel (res.data as any, { deep: true }) as T | |||
| } | |||
| export const apiGet = async <T> ( | |||
| path: string, | |||
| opt?: Opt, | |||
| ): Promise<T> => { | |||
| const res = await client.get (path, withUserCode (opt)) | |||
| return toCamel (res.data as any, { deep: true }) as T | |||
| } | |||
| export const apiPost = async <T> ( | |||
| path: string, | |||
| body?: unknown, | |||
| opt?: Opt, | |||
| ): Promise<T> => apiP ('post', path, body, opt) | |||
| export const apiPut = async <T> ( | |||
| path: string, | |||
| body?: unknown, | |||
| opt?: Opt, | |||
| ): Promise<T> => apiP ('put', path, body, opt) | |||
| export const apiPatch = async <T> ( | |||
| path: string, | |||
| body?: unknown, | |||
| opt?: Opt, | |||
| ): Promise<T> => apiP ('patch', path, body, opt) | |||
| export const apiDelete = async ( | |||
| path: string, | |||
| opt?: Opt, | |||
| ): Promise<void> => { | |||
| await client.delete (path, withUserCode (opt)) | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| import { apiDelete, apiGet, apiPost } from '@/lib/api' | |||
| import type { Post } from '@/types' | |||
| export const fetchPosts = async ( | |||
| { tags, match, page, limit, cursor }: { | |||
| tags: string | |||
| match: 'any' | 'all' | |||
| page?: number | |||
| limit?: number | |||
| cursor?: string } | |||
| ): Promise<{ | |||
| posts: Post[] | |||
| count: number | |||
| nextCursor: string }> => await apiGet ('/posts', { | |||
| params: { | |||
| tags, | |||
| match, | |||
| ...(page && { page }), | |||
| ...(limit && { limit }), | |||
| ...(cursor && { cursor }) } }) | |||
| export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) | |||
| export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | |||
| await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | |||
| } | |||
| @@ -0,0 +1,49 @@ | |||
| import { QueryClient } from '@tanstack/react-query' | |||
| import { match } from 'path-to-regexp' | |||
| import { fetchPost, fetchPosts } from '@/lib/posts' | |||
| import { postsKeys } from '@/lib/queryKeys' | |||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | |||
| const mPost = match<{ id: string }> ('/posts/:id') | |||
| const prefetchPostsIndex: Prefetcher = async (qc, url) => { | |||
| const tags = url.searchParams.get ('tags') ?? '' | |||
| const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all' | |||
| const page = Number (url.searchParams.get ('page') || 1) | |||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||
| await qc.prefetchQuery ({ | |||
| queryKey: postsKeys.index ({ tags, match: m, page, limit }), | |||
| queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) | |||
| } | |||
| const prefetchPostShow: Prefetcher = async (qc, url) => { | |||
| const m = mPost (url.pathname) | |||
| if (!(m)) | |||
| return | |||
| const { id } = m.params | |||
| await qc.prefetchQuery ({ | |||
| queryKey: postsKeys.show (id), | |||
| queryFn: () => fetchPost (id) }) | |||
| } | |||
| export const routePrefetchers: { | |||
| test: (u: URL) => boolean | |||
| run: Prefetcher }[] = [ | |||
| { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, | |||
| { test: u => Boolean (mPost (u.pathname)), run: prefetchPostShow }] | |||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | |||
| const u = new URL (urlLike, location.origin) | |||
| const jobs = routePrefetchers.filter (r => r.test (u)).map (r => r.run (qc, u)) | |||
| if (jobs.length === 0) | |||
| return | |||
| await Promise.all (jobs) | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| export const postsKeys = { | |||
| root: ['posts'] as const, | |||
| index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => | |||
| ['posts', 'index', p] as const, | |||
| show: (id: string) => ['posts', id] as const, | |||
| related: (id: string) => ['related', id] as const } | |||
| export const wikiKeys = { | |||
| root: ['wiki'] as const, | |||
| show: (title: string, p: { version: string }) => ['wiki', title, p] as const } | |||
| @@ -0,0 +1,7 @@ | |||
| import { apiGet } from '@/lib/api' | |||
| import type { Tag } from '@/types' | |||
| export const fetchTagByName = async (name: string): Promise<Tag> => | |||
| await apiGet (`/tags/name/${ name }`) | |||
| @@ -0,0 +1,14 @@ | |||
| import { apiGet } from '@/lib/api' | |||
| import type { WikiPage } from '@/types' | |||
| export const fetchWikiPage = async (id: string): Promise<WikiPage> => | |||
| await apiGet (`/wiki/${ id }`) | |||
| export const fetchWikiPageByTitle = async ( | |||
| title: string, | |||
| { version }: { version?: string }, | |||
| ): Promise<WikiPage> => | |||
| await apiGet (`/wiki/title/${ title }`, { params: version ? { version } : { } }) | |||
| @@ -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,74 +1,81 @@ | |||
| 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' | |||
| import PostList from '@/components/PostList' | |||
| import TagDetailSidebar from '@/components/TagDetailSidebar' | |||
| import PostEditForm from '@/components/PostEditForm' | |||
| import PostEmbed from '@/components/PostEmbed' | |||
| import PostList from '@/components/PostList' | |||
| import TagDetailSidebar from '@/components/TagDetailSidebar' | |||
| 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 { postsKeys } from '@/lib/queryKeys' | |||
| 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 } | |||
| export default (({ user }: Props) => { | |||
| const { id } = useParams () | |||
| const postId = String (id ?? '') | |||
| const postKey = postsKeys.show (postId) | |||
| const { data: post, isError: errorFlg, error } = useQuery ({ | |||
| enabled: Boolean (id), | |||
| queryKey: postKey, | |||
| queryFn: () => fetchPost (postId) }) | |||
| const qc = useQueryClient () | |||
| const [post, setPost] = useState<Post | null> (null) | |||
| 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 cur = qc.getQueryData<any> (postKey) | |||
| const next = !(cur?.viewed) | |||
| await toggleViewedFlg (postId, next) | |||
| return next | |||
| }, | |||
| onMutate: async () => { | |||
| await qc.cancelQueries ({ queryKey: postKey }) | |||
| const prev = qc.getQueryData<any> (postKey) | |||
| qc.setQueryData (postKey, | |||
| (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | |||
| return { prev } | |||
| }, | |||
| onError: (...[, , ctx]) => { | |||
| if (ctx?.prev) | |||
| qc.setQueryData (postKey, ctx.prev) | |||
| toast ({ title: '失敗……', description: '通信に失敗しました……' }) | |||
| }, | |||
| onSuccess: () => { | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| } }) | |||
| 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 () | |||
| const code = (error as any)?.response.status ?? (error as any)?.status | |||
| if (code) | |||
| setStatus (code) | |||
| }, [errorFlg, error]) | |||
| useEffect (() => { | |||
| scroll (0, 0) | |||
| setStatus (200) | |||
| }, [id]) | |||
| switch (status) | |||
| @@ -84,44 +91,67 @@ export default (({ user }: Props) => { | |||
| : 'bg-gray-500 hover:bg-gray-600') | |||
| return ( | |||
| <div className="md:flex md:flex-1"> | |||
| <Helmet> | |||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||
| <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}/> | |||
| </div> | |||
| <MainArea> | |||
| {post | |||
| ? ( | |||
| <> | |||
| <PostEmbed post={post}/> | |||
| <Button onClick={changeViewedFlg} | |||
| className={cn ('text-white', viewedClass)}> | |||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||
| </Button> | |||
| <TabGroup> | |||
| <Tab name="関聯"> | |||
| {post.related.length > 0 | |||
| ? <PostList posts={post.related}/> | |||
| : 'まだないよ(笑)'} | |||
| </Tab> | |||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||
| <Tab name="編輯"> | |||
| <PostEditForm post={post} | |||
| onSave={newPost => { | |||
| setPost (newPost) | |||
| toast ({ description: '更新しました.' }) | |||
| }}/> | |||
| </Tab>)} | |||
| </TabGroup> | |||
| </>) | |||
| : 'Loading...'} | |||
| </MainArea> | |||
| <div className="md:hidden"> | |||
| <TagDetailSidebar post={post}/> | |||
| <> | |||
| <div className="md:flex md:flex-1"> | |||
| <Helmet> | |||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||
| <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 ?? null}/> | |||
| </div> | |||
| <MainArea className="relative"> | |||
| {post | |||
| ? ( | |||
| <> | |||
| {(post.thumbnail || post.thumbnailBase) && ( | |||
| <motion.div | |||
| layoutId={`page-${ id }`} | |||
| className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px] | |||
| overflow-hidden rounded-xl pointer-events-none z-50" | |||
| initial={{ opacity: 1 }} | |||
| animate={{ opacity: 0 }} | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| <img src={post.thumbnail || post.thumbnailBase} | |||
| alt={post.title || post.url} | |||
| title={post.title || post.url || undefined} | |||
| className="object-cover w-full h-full"/> | |||
| </motion.div>)} | |||
| <PostEmbed post={post}/> | |||
| <Button onClick={() => changeViewedFlg.mutate ()} | |||
| disabled={changeViewedFlg.isPending} | |||
| className={cn ('text-white', viewedClass)}> | |||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||
| </Button> | |||
| <TabGroup> | |||
| <Tab name="関聯"> | |||
| {post.related.length > 0 | |||
| ? <PostList posts={post.related}/> | |||
| : 'まだないよ(笑)'} | |||
| </Tab> | |||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||
| <Tab name="編輯"> | |||
| <PostEditForm | |||
| post={post} | |||
| onSave={newPost => { | |||
| qc.setQueryData (postsKeys.show (postId), | |||
| (prev: any) => newPost ?? prev) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| toast ({ description: '更新しました.' }) | |||
| }}/> | |||
| </Tab>)} | |||
| </TabGroup> | |||
| </>) | |||
| : 'Loading...'} | |||
| </MainArea> | |||
| <div className="md:hidden"> | |||
| <TagDetailSidebar post={post ?? null}/> | |||
| </div> | |||
| </div> | |||
| </div>) | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,113 +1,66 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useEffect, useLayoutEffect, useRef, useState } from 'react' | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import { useLayoutEffect, useRef, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { Link, useLocation, useNavigationType } from 'react-router-dom' | |||
| import { useLocation } from 'react-router-dom' | |||
| import PostList from '@/components/PostList' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import TagSidebar from '@/components/TagSidebar' | |||
| import WikiBody from '@/components/WikiBody' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { fetchPosts } from '@/lib/posts' | |||
| import { postsKeys } from '@/lib/queryKeys' | |||
| import { fetchWikiPageByTitle } from '@/lib/wiki' | |||
| import type { Post, WikiPage } from '@/types' | |||
| import type { FC } from 'react' | |||
| import type { WikiPage } from '@/types' | |||
| export default () => { | |||
| const navigationType = useNavigationType () | |||
| export default (() => { | |||
| const containerRef = useRef<HTMLDivElement | null> (null) | |||
| const loaderRef = useRef<HTMLDivElement | null> (null) | |||
| const [cursor, setCursor] = useState ('') | |||
| const [loading, setLoading] = useState (false) | |||
| const [posts, setPosts] = useState<Post[]> ([]) | |||
| const [totalPages, setTotalPages] = useState (0) | |||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | |||
| const loadMore = async (withCursor: boolean) => { | |||
| setLoading (true) | |||
| const res = await axios.get (`${ API_BASE_URL }/posts`, { | |||
| params: { tags: tags.join (' '), | |||
| match: anyFlg ? 'any' : 'all', | |||
| ...(page && { page }), | |||
| ...(limit && { limit }), | |||
| ...(withCursor && { cursor }) } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as { | |||
| posts: Post[] | |||
| count: number | |||
| nextCursor: string } | |||
| setPosts (posts => ( | |||
| [...((new Map ([...(withCursor ? posts : []), ...data.posts] | |||
| .map (post => [post.id, post]))) | |||
| .values ())])) | |||
| setCursor (data.nextCursor) | |||
| setTotalPages (Math.ceil (data.count / limit)) | |||
| setLoading (false) | |||
| } | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const tagsQuery = query.get ('tags') ?? '' | |||
| const anyFlg = query.get ('match') === 'any' | |||
| const match = anyFlg ? 'any' : 'all' | |||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | |||
| const tagsKey = tags.join (' ') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| useEffect(() => { | |||
| const observer = new IntersectionObserver (entries => { | |||
| if (entries[0].isIntersecting && !(loading) && cursor) | |||
| loadMore (true) | |||
| }, { threshold: 1 }) | |||
| const target = loaderRef.current | |||
| target && observer.observe (target) | |||
| return () => { | |||
| target && observer.unobserve (target) | |||
| } | |||
| }, [loaderRef, loading]) | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }), | |||
| queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) }) | |||
| const posts = data?.posts ?? [] | |||
| const cursor = data?.nextCursor ?? '' | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| useLayoutEffect (() => { | |||
| // TODO: 無限ロード用 | |||
| const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null | |||
| if (savedState && navigationType === 'POP') | |||
| { | |||
| const { posts, cursor, scroll } = JSON.parse (savedState) | |||
| setPosts (posts) | |||
| setCursor (cursor) | |||
| scroll (0, 0) | |||
| if (containerRef.current) | |||
| containerRef.current.scrollTop = scroll | |||
| setWikiPage (null) | |||
| loadMore (true) | |||
| } | |||
| else | |||
| if (tags.length !== 1) | |||
| return | |||
| void (async () => { | |||
| try | |||
| { | |||
| setPosts ([]) | |||
| loadMore (false) | |||
| const tagName = tags[0] | |||
| setWikiPage (await fetchWikiPageByTitle (tagName, { })) | |||
| } | |||
| setWikiPage (null) | |||
| if (tags.length === 1) | |||
| catch | |||
| { | |||
| void (async () => { | |||
| try | |||
| { | |||
| const tagName = tags[0] | |||
| const res = await axios.get (`${ API_BASE_URL }/wiki/title/${ tagName }`) | |||
| setWikiPage (toCamel (res.data as any, { deep: true }) as WikiPage) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) () | |||
| ; | |||
| } | |||
| }) () | |||
| }, [location.search]) | |||
| return ( | |||
| @@ -120,7 +73,13 @@ export default () => { | |||
| </title> | |||
| </Helmet> | |||
| <TagSidebar posts={posts.slice (0, 20)}/> | |||
| <TagSidebar posts={posts.slice (0, 20)} onClick={() => { | |||
| const statesToSave = { | |||
| posts, cursor, | |||
| scroll: containerRef.current?.scrollTop ?? 0 } | |||
| sessionStorage.setItem (`posts:${ tagsQuery }`, | |||
| JSON.stringify (statesToSave)) | |||
| }}/> | |||
| <MainArea> | |||
| <TabGroup> | |||
| @@ -128,31 +87,22 @@ export default () => { | |||
| {posts.length > 0 | |||
| ? ( | |||
| <> | |||
| <PostList posts={posts} onClick={() => { | |||
| // TODO: 無限ロード用なので復活時に戻す. | |||
| // const statesToSave = { | |||
| // posts, cursor, | |||
| // scroll: containerRef.current?.scrollTop ?? 0 } | |||
| // sessionStorage.setItem (`posts:${ tagsQuery }`, | |||
| // JSON.stringify (statesToSave)) | |||
| }}/> | |||
| <PostList posts={posts}/> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>) | |||
| : !(loading) && '広場には何もありませんよ.'} | |||
| {loading && 'Loading...'} | |||
| {/* TODO: 無限ローディング復活までコメント・アウト */} | |||
| {/* <div ref={loaderRef} className="h-12"/> */} | |||
| </Tab> | |||
| {tags.length === 1 && ( | |||
| <Tab name="Wiki"> | |||
| <WikiBody title={tags[0]} body={wikiPage?.body}/> | |||
| <div className="my-2"> | |||
| <Link to={`/wiki/${ encodeURIComponent (tags[0]) }`}> | |||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (tags[0]) }`}> | |||
| Wiki を見る | |||
| </Link> | |||
| </PrefetchLink> | |||
| </div> | |||
| </Tab>)} | |||
| </TabGroup> | |||
| </MainArea> | |||
| </div>) | |||
| } | |||
| }) satisfies FC | |||
| @@ -1,17 +1,19 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useEffect, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' | |||
| import { useLocation, useNavigate, useParams } from 'react-router-dom' | |||
| import PostList from '@/components/PostList' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import TagLink from '@/components/TagLink' | |||
| import WikiBody from '@/components/WikiBody' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | |||
| import { fetchPosts } from '@/lib/posts' | |||
| import { fetchTagByName } from '@/lib/tags' | |||
| import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' | |||
| import type { Post, Tag, WikiPage } from '@/types' | |||
| @@ -39,8 +41,7 @@ export default () => { | |||
| setWikiPage (undefined) | |||
| try | |||
| { | |||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) | |||
| const data = res.data as WikiPage | |||
| const data = await fetchWikiPage (title) | |||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||
| } | |||
| catch | |||
| @@ -56,10 +57,7 @@ export default () => { | |||
| setWikiPage (undefined) | |||
| try | |||
| { | |||
| const res = await axios.get ( | |||
| `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, | |||
| { params: version ? { version } : { } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as WikiPage | |||
| const data = await fetchWikiPageByTitle (title, version ? { version } : { }) | |||
| if (data.title !== title) | |||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||
| setWikiPage (data) | |||
| @@ -75,12 +73,7 @@ export default () => { | |||
| void (async () => { | |||
| try | |||
| { | |||
| const res = await axios.get ( | |||
| `${ API_BASE_URL }/posts?${ new URLSearchParams ({ tags: title, | |||
| limit: '8' }) }`) | |||
| const data = toCamel (res.data as any, | |||
| { deep: true }) as { posts: Post[] | |||
| nextCursor: string } | |||
| const data = await fetchPosts ({ tags: title, match: 'all', limit: 8 }) | |||
| setPosts (data.posts) | |||
| } | |||
| catch | |||
| @@ -92,9 +85,7 @@ export default () => { | |||
| void (async () => { | |||
| try | |||
| { | |||
| const res = await axios.get ( | |||
| `${ API_BASE_URL }/tags/name/${ encodeURIComponent (title) }`) | |||
| setTag (toCamel (res.data as any, { deep: true }) as Tag) | |||
| setTag (await fetchTagByName (title)) | |||
| } | |||
| catch | |||
| { | |||
| @@ -115,16 +106,16 @@ export default () => { | |||
| {(wikiPage && version) && ( | |||
| <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | |||
| {wikiPage.pred ? ( | |||
| <Link to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | |||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | |||
| < 古 | |||
| </Link>) : '(最古)'} | |||
| </PrefetchLink>) : '(最古)'} | |||
| <span>{wikiPage.updatedAt}</span> | |||
| {wikiPage.succ ? ( | |||
| <Link to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}> | |||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}> | |||
| 新 > | |||
| </Link>) : '(最新)'} | |||
| </PrefetchLink>) : '(最新)'} | |||
| </div>)} | |||
| <PageTitle> | |||
| @@ -0,0 +1,19 @@ | |||
| import { create } from 'zustand' | |||
| type SharedTransitionState = { | |||
| byLocationKey: Record<string, string> | |||
| setForLocationKey: (locationKey: string, sharedId: string) => void | |||
| clearForLocationKey: (locationKey: string) => void } | |||
| export const useSharedTransitionStore = create<SharedTransitionState> (set => ({ | |||
| byLocationKey: { }, | |||
| setForLocationKey: (locationKey, sharedId) => | |||
| set (state => ({ byLocationKey: { ...state.byLocationKey, | |||
| [locationKey]: sharedId } })), | |||
| clearForLocationKey: (locationKey) => | |||
| set (state => { | |||
| const next = { ...state.byLocationKey } | |||
| delete next[locationKey] | |||
| return { byLocationKey: next } | |||
| }) })) | |||