| @@ -1,12 +1,12 @@ | |||||
| import { useQueryClient } from '@tanstack/react-query' | import { useQueryClient } from '@tanstack/react-query' | ||||
| import { useMemo } from 'react' | |||||
| import { forwardRef, useMemo } from 'react' | |||||
| import { createPath, useNavigate } from 'react-router-dom' | import { createPath, useNavigate } from 'react-router-dom' | ||||
| import { useOverlayStore } from '@/components/RouteBlockerOverlay' | import { useOverlayStore } from '@/components/RouteBlockerOverlay' | ||||
| import { prefetchForURL } from '@/lib/prefetchers' | import { prefetchForURL } from '@/lib/prefetchers' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { AnchorHTMLAttributes, FC, MouseEvent, TouchEvent } from 'react' | |||||
| import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' | |||||
| import type { To } from 'react-router-dom' | import type { To } from 'react-router-dom' | ||||
| type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { | type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { | ||||
| @@ -16,14 +16,15 @@ type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { | |||||
| cancelOnError?: boolean } | cancelOnError?: boolean } | ||||
| export default (({ to, | |||||
| replace, | |||||
| className, | |||||
| onMouseEnter, | |||||
| onTouchStart, | |||||
| onClick, | |||||
| cancelOnError = false, | |||||
| ...rest }: Props) => { | |||||
| export default forwardRef<HTMLAnchorElement, Props> (({ | |||||
| to, | |||||
| replace, | |||||
| className, | |||||
| onMouseEnter, | |||||
| onTouchStart, | |||||
| onClick, | |||||
| cancelOnError = false, | |||||
| ...rest }, ref) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| const url = useMemo (() => { | const url = useMemo (() => { | ||||
| @@ -47,12 +48,12 @@ export default (({ to, | |||||
| const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => { | const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => { | ||||
| onMouseEnter?.(ev) | onMouseEnter?.(ev) | ||||
| doPrefetch () | |||||
| await doPrefetch () | |||||
| } | } | ||||
| const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { | const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { | ||||
| onTouchStart?.(ev) | onTouchStart?.(ev) | ||||
| doPrefetch () | |||||
| await doPrefetch () | |||||
| } | } | ||||
| const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => { | ||||
| @@ -78,10 +79,11 @@ export default (({ to, | |||||
| } | } | ||||
| return ( | return ( | ||||
| <a href={typeof to === 'string' ? to : createPath (to)} | |||||
| <a ref={ref} | |||||
| href={typeof to === 'string' ? to : createPath (to)} | |||||
| onMouseEnter={handleMouseEnter} | onMouseEnter={handleMouseEnter} | ||||
| onTouchStart={handleTouchStart} | onTouchStart={handleTouchStart} | ||||
| onClick={handleClick} | onClick={handleClick} | ||||
| className={cn ('cursor-pointer', className)} | className={cn ('cursor-pointer', className)} | ||||
| {...rest}/>) | {...rest}/>) | ||||
| }) satisfies FC<Props> | |||||
| }) | |||||
| @@ -1,18 +1,18 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { AnimatePresence, motion } from 'framer-motion' | import { AnimatePresence, motion } from 'framer-motion' | ||||
| import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | 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 Separator from '@/components/MenuSeparator' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TopNavUser from '@/components/TopNavUser' | import TopNavUser from '@/components/TopNavUser' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | ||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { cn } from '@/lib/utils' | 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 } | type Props = { user: User | null } | ||||
| @@ -120,11 +120,8 @@ export default (({ user }: Props) => { | |||||
| const fetchPostCount = async () => { | const fetchPostCount = async () => { | ||||
| try | 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) | setPostCount (tag.postCount) | ||||
| } | } | ||||
| @@ -141,11 +138,11 @@ export default (({ user }: Props) => { | |||||
| <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | <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"> | bg-yellow-200 dark:bg-red-975 md:bg-yellow-50"> | ||||
| <div className="flex items-center gap-2 h-full"> | <div className="flex items-center gap-2 h-full"> | ||||
| <Link to="/" | |||||
| <PrefetchLink to="/" | |||||
| className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 | className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 | ||||
| dark:text-pink-300 dark:hover:text-pink-100"> | dark:text-pink-300 dark:hover:text-pink-100"> | ||||
| ぼざクリ タグ広場 | ぼざクリ タグ広場 | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| <div ref={navRef} className="relative hidden md:flex h-full items-center"> | <div ref={navRef} className="relative hidden md:flex h-full items-center"> | ||||
| <div aria-hidden | <div aria-hidden | ||||
| @@ -157,15 +154,16 @@ export default (({ user }: Props) => { | |||||
| opacity: hl.visible ? 1 : 0 }}/> | opacity: hl.visible ? 1 : 0 }}/> | ||||
| {menu.map ((item, i) => ( | {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} | {item.name} | ||||
| </Link>))} | |||||
| </PrefetchLink>))} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -203,11 +201,12 @@ export default (({ user }: Props) => { | |||||
| 'component' in item | 'component' in item | ||||
| ? <Fragment key={`c-${ i }`}>{item.component}</Fragment> | ? <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} | {item.name} | ||||
| </Link>)))} | |||||
| </PrefetchLink>)))} | |||||
| </motion.div> | </motion.div> | ||||
| </AnimatePresence> | </AnimatePresence> | ||||
| </div> | </div> | ||||
| @@ -229,19 +228,20 @@ export default (({ user }: Props) => { | |||||
| <Separator/> | <Separator/> | ||||
| {menu.map ((item, i) => ( | {menu.map ((item, i) => ( | ||||
| <Fragment key={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} | {item.name} | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| <AnimatePresence initial={false}> | <AnimatePresence initial={false}> | ||||
| {i === openItemIdx && ( | {i === openItemIdx && ( | ||||
| @@ -267,11 +267,12 @@ export default (({ user }: Props) => { | |||||
| {subItem.component} | {subItem.component} | ||||
| </Fragment>) | </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} | {subItem.name} | ||||
| </Link>)))} | |||||
| </PrefetchLink>)))} | |||||
| </motion.div>)} | </motion.div>)} | ||||
| </AnimatePresence> | </AnimatePresence> | ||||
| </Fragment>))} | </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' | 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"> | <nav className="mt-4 flex justify-center" aria-label="Pagination"> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| {(page > 1) | {(page > 1) | ||||
| ? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link> | |||||
| ? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ"><</PrefetchLink> | |||||
| : <span aria-hidden><</span>} | : <span aria-hidden><</span>} | ||||
| {pages.map ((p, idx) => ( | {pages.map ((p, idx) => ( | ||||
| @@ -69,10 +71,10 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||||
| ? <span key={`dots-${ idx }`}>…</span> | ? <span key={`dots-${ idx }`}>…</span> | ||||
| : ((p === page) | : ((p === page) | ||||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | ? <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) | {(page < totalPages) | ||||
| ? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link> | |||||
| ? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">></PrefetchLink> | |||||
| : <span aria-hidden>></span>} | : <span aria-hidden>></span>} | ||||
| </div> | </div> | ||||
| </nav>) | </nav>) | ||||
| @@ -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)) | |||||
| } | |||||
| @@ -1,7 +1,4 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiDelete, apiGet, apiPost } from '@/lib/api' | |||||
| import type { Post } from '@/types' | import type { Post } from '@/types' | ||||
| @@ -16,35 +13,18 @@ export const fetchPosts = async ( | |||||
| ): Promise<{ | ): Promise<{ | ||||
| posts: Post[] | posts: Post[] | ||||
| count: number | count: number | ||||
| nextCursor: string }> => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/posts`, { | |||||
| params: { | |||||
| tags, | |||||
| match, | |||||
| ...(page && { page }), | |||||
| ...(limit && { limit }), | |||||
| ...(cursor && { cursor }) } }) | |||||
| return toCamel (res.data as any, { deep: true }) as { | |||||
| posts: Post[] | |||||
| count: number | |||||
| nextCursor: string } | |||||
| } | |||||
| nextCursor: string }> => await apiGet ('/posts', { | |||||
| params: { | |||||
| tags, | |||||
| match, | |||||
| ...(page && { page }), | |||||
| ...(limit && { limit }), | |||||
| ...(cursor && { cursor }) } }) | |||||
| 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 fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) | |||||
| export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | 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) | |||||
| await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | |||||
| } | } | ||||
| @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query' | |||||
| import { match } from 'path-to-regexp' | import { match } from 'path-to-regexp' | ||||
| import { fetchPost, fetchPosts } from '@/lib/posts' | import { fetchPost, fetchPosts } from '@/lib/posts' | ||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | ||||
| @@ -10,11 +11,12 @@ const mPost = match<{ id: string }> ('/posts/:id') | |||||
| const prefetchPostsIndex: Prefetcher = async (qc, url) => { | const prefetchPostsIndex: Prefetcher = async (qc, url) => { | ||||
| const tags = url.searchParams.get ('tags') ?? '' | const tags = url.searchParams.get ('tags') ?? '' | ||||
| const match = url.searchParams.get ('match') === 'any' ? 'any' : 'all' | |||||
| 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) | const limit = Number (url.searchParams.get ('limit') || 20) | ||||
| await qc.prefetchQuery ({ | await qc.prefetchQuery ({ | ||||
| queryKey: ['posts', 'index', { tags, match, limit }], | |||||
| queryFn: () => fetchPosts ({ tags, match, limit }) }) | |||||
| queryKey: postsKeys.index ({ tags, match: m, page, limit }), | |||||
| queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) | |||||
| } | } | ||||
| @@ -25,12 +27,14 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { | |||||
| const { id } = m.params | const { id } = m.params | ||||
| await qc.prefetchQuery ({ | await qc.prefetchQuery ({ | ||||
| queryKey: ['posts', id], | |||||
| queryKey: postsKeys.show (id), | |||||
| queryFn: () => fetchPost (id) }) | queryFn: () => fetchPost (id) }) | ||||
| } | } | ||||
| export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | |||||
| export const routePrefetchers: { | |||||
| test: (u: URL) => boolean | |||||
| run: Prefetcher }[] = [ | |||||
| { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, | { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, | ||||
| { test: u => Boolean (mPost (u.pathname)), run: prefetchPostShow }] | { test: u => Boolean (mPost (u.pathname)), run: prefetchPostShow }] | ||||
| @@ -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 } : { } }) | |||||
| @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' | |||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | import { fetchPost, toggleViewedFlg } from '@/lib/posts' | ||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| 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' | ||||
| @@ -26,12 +27,13 @@ type Props = { user: User | null } | |||||
| export default (({ user }: Props) => { | export default (({ user }: Props) => { | ||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const postId = String (id ?? '') | |||||
| const postKey = postsKeys.show (postId) | |||||
| const { data: post, isError: errorFlg, error } = useQuery ({ | const { data: post, isError: errorFlg, error } = useQuery ({ | ||||
| enabled: Boolean (id), | |||||
| queryKey: ['posts', String (id)], | |||||
| queryFn: () => fetchPost (String (id)), | |||||
| placeholderData: undefined }) | |||||
| enabled: Boolean (id), | |||||
| queryKey: postKey, | |||||
| queryFn: () => fetchPost (postId) }) | |||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| @@ -39,25 +41,25 @@ export default (({ user }: Props) => { | |||||
| const changeViewedFlg = useMutation ({ | const changeViewedFlg = useMutation ({ | ||||
| mutationFn: async () => { | mutationFn: async () => { | ||||
| const next = !(post!.viewed) | |||||
| await toggleViewedFlg (id!, next) | |||||
| const cur = qc.getQueryData<any> (postKey) | |||||
| const next = !(cur?.viewed) | |||||
| await toggleViewedFlg (postId, next) | |||||
| return next | return next | ||||
| }, | }, | ||||
| onMutate: async () => { | onMutate: async () => { | ||||
| await qc.cancelQueries ({ queryKey: ['posts', String (id)] }) | |||||
| const prev = qc.getQueryData<any> (['posts', String (id)]) | |||||
| qc.setQueryData (['posts', String (id)], | |||||
| await qc.cancelQueries ({ queryKey: postKey }) | |||||
| const prev = qc.getQueryData<any> (postKey) | |||||
| qc.setQueryData (postKey, | |||||
| (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | ||||
| return { prev } | return { prev } | ||||
| }, | }, | ||||
| onError: (...[, , ctx]) => { | onError: (...[, , ctx]) => { | ||||
| if (ctx?.prev) | if (ctx?.prev) | ||||
| qc.setQueryData (['posts', String (id)], ctx.prev) | |||||
| qc.setQueryData (postKey, ctx.prev) | |||||
| toast ({ title: '失敗……', description: '通信に失敗しました……' }) | toast ({ title: '失敗……', description: '通信に失敗しました……' }) | ||||
| }, | }, | ||||
| onSuccess: () => { | onSuccess: () => { | ||||
| qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) | |||||
| qc.invalidateQueries ({ queryKey: ['related', String (id)] }) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| } }) | } }) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -115,14 +117,14 @@ export default (({ user }: Props) => { | |||||
| </Tab> | </Tab> | ||||
| {['admin', 'member'].some (r => user?.role === r) && ( | {['admin', 'member'].some (r => user?.role === r) && ( | ||||
| <Tab name="編輯"> | <Tab name="編輯"> | ||||
| <PostEditForm post={post} | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (['posts', String (id)], | |||||
| (prev: any) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: ['posts', 'index'] }) | |||||
| qc.invalidateQueries ({ queryKey: ['related', String (id)] }) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }}/> | |||||
| <PostEditForm | |||||
| post={post} | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (postsKeys.show (postId), | |||||
| (prev: any) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }}/> | |||||
| </Tab>)} | </Tab>)} | ||||
| </TabGroup> | </TabGroup> | ||||
| </>) | </>) | ||||
| @@ -1,94 +1,46 @@ | |||||
| 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 { 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 PostList from '@/components/PostList' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TagSidebar from '@/components/TagSidebar' | import TagSidebar from '@/components/TagSidebar' | ||||
| import WikiBody from '@/components/WikiBody' | import WikiBody from '@/components/WikiBody' | ||||
| import Pagination from '@/components/common/Pagination' | import Pagination from '@/components/common/Pagination' | ||||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | import TabGroup, { Tab } from '@/components/common/TabGroup' | ||||
| import MainArea from '@/components/layout/MainArea' | 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 { fetchPosts } from '@/lib/posts' | ||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| import { fetchWikiPageByTitle } from '@/lib/wiki' | |||||
| import type { Post, WikiPage } from '@/types' | |||||
| import type { WikiPage } from '@/types' | |||||
| export default () => { | export default () => { | ||||
| const navigationType = useNavigationType () | |||||
| const containerRef = useRef<HTMLDivElement | null> (null) | 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 [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | ||||
| const loadMore = async (withCursor: boolean) => { | |||||
| setLoading (true) | |||||
| const data = await fetchPosts ({ | |||||
| tags: tags.join (' '), | |||||
| match: anyFlg ? 'any' : 'all', | |||||
| ...(page && { page }), | |||||
| ...(limit && { limit }), | |||||
| ...(withCursor && { cursor }) }) | |||||
| 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 location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const tagsQuery = query.get ('tags') ?? '' | const tagsQuery = query.get ('tags') ?? '' | ||||
| const anyFlg = query.get ('match') === 'any' | const anyFlg = query.get ('match') === 'any' | ||||
| const match = anyFlg ? 'any' : 'all' | |||||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | const tags = tagsQuery.split (' ').filter (e => e !== '') | ||||
| const tagsKey = tags.join (' ') | |||||
| const page = Number (query.get ('page') ?? 1) | const page = Number (query.get ('page') ?? 1) | ||||
| const limit = Number (query.get ('limit') ?? 20) | 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 (() => { | useLayoutEffect (() => { | ||||
| // TODO: 無限ロード用 | |||||
| const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null | |||||
| if (savedState && navigationType === 'POP') | |||||
| { | |||||
| const { posts, cursor, scroll } = JSON.parse (savedState) | |||||
| setPosts (posts) | |||||
| setCursor (cursor) | |||||
| if (containerRef.current) | |||||
| containerRef.current.scrollTop = scroll | |||||
| loadMore (true) | |||||
| } | |||||
| else | |||||
| { | |||||
| setPosts ([]) | |||||
| loadMore (false) | |||||
| } | |||||
| setWikiPage (null) | setWikiPage (null) | ||||
| if (tags.length === 1) | if (tags.length === 1) | ||||
| { | { | ||||
| @@ -96,8 +48,7 @@ export default () => { | |||||
| try | try | ||||
| { | { | ||||
| const tagName = tags[0] | 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) | |||||
| setWikiPage (await fetchWikiPageByTitle (tagName, { })) | |||||
| } | } | ||||
| catch | catch | ||||
| { | { | ||||
| @@ -131,28 +82,19 @@ export default () => { | |||||
| {posts.length > 0 | {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}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>) | </>) | ||||
| : !(loading) && '広場には何もありませんよ.'} | : !(loading) && '広場には何もありませんよ.'} | ||||
| {loading && 'Loading...'} | {loading && 'Loading...'} | ||||
| {/* TODO: 無限ローディング復活までコメント・アウト */} | |||||
| {/* <div ref={loaderRef} className="h-12"/> */} | |||||
| </Tab> | </Tab> | ||||
| {tags.length === 1 && ( | {tags.length === 1 && ( | ||||
| <Tab name="Wiki"> | <Tab name="Wiki"> | ||||
| <WikiBody title={tags[0]} body={wikiPage?.body}/> | <WikiBody title={tags[0]} body={wikiPage?.body}/> | ||||
| <div className="my-2"> | <div className="my-2"> | ||||
| <Link to={`/wiki/${ encodeURIComponent (tags[0]) }`}> | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (tags[0]) }`}> | |||||
| Wiki を見る | Wiki を見る | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| </div> | </div> | ||||
| </Tab>)} | </Tab>)} | ||||
| </TabGroup> | </TabGroup> | ||||
| @@ -1,17 +1,19 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | 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 PostList from '@/components/PostList' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import WikiBody from '@/components/WikiBody' | import WikiBody from '@/components/WikiBody' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | import TabGroup, { Tab } from '@/components/common/TabGroup' | ||||
| import MainArea from '@/components/layout/MainArea' | 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 { 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' | import type { Post, Tag, WikiPage } from '@/types' | ||||
| @@ -39,8 +41,7 @@ export default () => { | |||||
| setWikiPage (undefined) | setWikiPage (undefined) | ||||
| try | 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 }) | navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | ||||
| } | } | ||||
| catch | catch | ||||
| @@ -56,10 +57,7 @@ export default () => { | |||||
| setWikiPage (undefined) | setWikiPage (undefined) | ||||
| try | 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) | if (data.title !== title) | ||||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | ||||
| setWikiPage (data) | setWikiPage (data) | ||||
| @@ -75,12 +73,7 @@ export default () => { | |||||
| void (async () => { | void (async () => { | ||||
| try | 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) | setPosts (data.posts) | ||||
| } | } | ||||
| catch | catch | ||||
| @@ -92,9 +85,7 @@ export default () => { | |||||
| void (async () => { | void (async () => { | ||||
| try | 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 | catch | ||||
| { | { | ||||
| @@ -115,16 +106,16 @@ export default () => { | |||||
| {(wikiPage && version) && ( | {(wikiPage && version) && ( | ||||
| <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | ||||
| {wikiPage.pred ? ( | {wikiPage.pred ? ( | ||||
| <Link to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | |||||
| < 古 | < 古 | ||||
| </Link>) : '(最古)'} | |||||
| </PrefetchLink>) : '(最古)'} | |||||
| <span>{wikiPage.updatedAt}</span> | <span>{wikiPage.updatedAt}</span> | ||||
| {wikiPage.succ ? ( | {wikiPage.succ ? ( | ||||
| <Link to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}> | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}> | |||||
| 新 > | 新 > | ||||
| </Link>) : '(最新)'} | |||||
| </PrefetchLink>) : '(最新)'} | |||||
| </div>)} | </div>)} | ||||
| <PageTitle> | <PageTitle> | ||||