プリフェッチ実装(#140) (#256)

Merge branch 'main' into feature/140

#140

Merge remote-tracking branch 'origin/main' into feature/140

#140

#140

#140

#140

#140

Merge remote-tracking branch 'origin/main' into feature/140

#140

#140

#140

#140

#140

#140

#140

#140

#140

#140

#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

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #256
This commit was merged in pull request #256.
This commit is contained in:
2026-02-11 13:27:28 +09:00
parent 1a776e348a
commit eb975e5301
30 changed files with 517 additions and 488 deletions
+7 -11
View File
@@ -1,5 +1,3 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, LayoutGroup } from 'framer-motion' import { AnimatePresence, LayoutGroup } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
@@ -11,7 +9,7 @@ import { BrowserRouter,
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { API_BASE_URL } from '@/config' import { apiPost, isApiError } from '@/lib/api'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
@@ -75,12 +73,11 @@ export default (() => {
useEffect (() => { useEffect (() => {
const createUser = async () => { const createUser = async () => {
const res = await axios.post (`${ API_BASE_URL }/users`) const data = await apiPost<{ code: string; user: User }> ('/users')
const data = res.data as { code: string; user: any }
if (data.code) if (data.code)
{ {
localStorage.setItem ('user_code', data.code) localStorage.setItem ('user_code', data.code)
setUser (toCamel (data.user, { deep: true }) as User) setUser (data.user)
} }
} }
@@ -90,17 +87,16 @@ export default (() => {
void (async () => { void (async () => {
try try
{ {
const res = await axios.post (`${ API_BASE_URL }/users/verify`, { code }) const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
const data = res.data as { valid: boolean, user: any }
if (data.valid) if (data.valid)
setUser (toCamel (data.user, { deep: true })) setUser (data.user)
else else
await createUser () await createUser ()
} }
catch (err) catch (err)
{ {
if (axios.isAxiosError (err)) if (isApiError (err))
setStatus (err.status ?? 200) setStatus (err.response?.status ?? 200)
} }
}) () }) ()
} }
+5 -10
View File
@@ -1,12 +1,10 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { API_BASE_URL } from '@/config' import { apiPut } from '@/lib/api'
import type { FC } from 'react' import type { FC } from 'react'
@@ -41,14 +39,11 @@ export default (({ post, onSave }: Props) => {
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const handleSubmit = async () => { const handleSubmit = async () => {
const res = await axios.put ( const data = await apiPut<Post> (
`${ API_BASE_URL }/posts/${ post.id }`, `/posts/${ post.id }`,
{ title, tags, { title, tags, original_created_from: originalCreatedFrom,
original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore }, original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data', { headers: { 'Content-Type': 'multipart/form-data' } })
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Post
onSave ({ ...post, onSave ({ ...post,
title: data.title, title: data.title,
tags: data.tags, tags: data.tags,
+2 -5
View File
@@ -1,11 +1,9 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { API_BASE_URL } from '@/config' import { apiGet } from '@/lib/api'
import type { FC, SyntheticEvent } from 'react' import type { FC, SyntheticEvent } from 'react'
@@ -59,8 +57,7 @@ export default (({ tags, setTags }: Props) => {
const recompute = async (pos: number, v: string = tags) => { const recompute = async (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos) const { start, end, token } = getTokenAt (v, pos)
setBounds ({ start, end }) setBounds ({ start, end })
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } }) const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token } })
const data = toCamel (res.data as any, { deep: true }) as Tag[]
setSuggestions (data.filter (t => t.postCount > 0)) setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0) setSuggestionsVsbl (suggestions.length > 0)
} }
+45 -48
View File
@@ -21,53 +21,50 @@ export default (({ posts, onClick }: Props) => {
const cardRef = useRef<HTMLDivElement> (null) const cardRef = useRef<HTMLDivElement> (null)
return ( return (
<> <div className="flex flex-wrap gap-6 p-4">
<div className="flex flex-wrap gap-6 p-4"> {posts.map ((post, i) => {
{posts.map ((post, i) => { const sharedId = `page-${ post.id }`
const id2 = `page-${ post.id }` const layoutId = sharedId
const layoutId = id2
return ( return (
<PrefetchLink <PrefetchLink
to={`/posts/${ post.id }`} to={`/posts/${ post.id }`}
key={post.id} key={post.id}
className="w-40 h-40" className="w-40 h-40"
state={{ sharedId: `page-${ post.id }` }} state={{ sharedId }}
onClick={e => { onClick={e => {
const sharedId = `page-${ post.id }` setForLocationKey (location.key, sharedId)
setForLocationKey (location.key, sharedId) onClick?.(e)
onClick?.(e) }}>
}}> <motion.div
<motion.div ref={cardRef}
ref={cardRef} layoutId={layoutId}
layoutId={layoutId} className="w-full h-full overflow-hidden rounded-xl shadow
className="w-full h-full overflow-hidden rounded-xl shadow transform-gpu will-change-transform"
transform-gpu will-change-transform" whileHover={{ scale: 1.02 }}
whileHover={{ scale: 1.02 }} onLayoutAnimationStart={() => {
onLayoutAnimationStart={() => { if (!(cardRef.current))
if (cardRef.current) return
{
cardRef.current.style.position = 'relative' cardRef.current.style.position = 'relative'
cardRef.current.style.zIndex = '9999' cardRef.current.style.zIndex = '9999'
} }}
}} onLayoutAnimationComplete={() => {
onLayoutAnimationComplete={() => { if (!(cardRef.current))
if (cardRef.current) return
{
cardRef.current.style.zIndex = '' cardRef.current.style.zIndex = ''
cardRef.current.style.position = '' cardRef.current.style.position = ''
} }}
}} transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}> <img src={post.thumbnail || post.thumbnailBase || undefined}
<img src={post.thumbnail || post.thumbnailBase || undefined} alt={post.title || post.url}
alt={post.title || post.url} title={post.title || post.url || undefined}
title={post.title || post.url || undefined} loading={i < 12 ? 'eager' : 'lazy'}
loading={i < 12 ? 'eager' : 'lazy'} decoding="async"
decoding="async" className="object-cover w-full h-full"/>
className="object-cover w-full h-full"/> </motion.div>
</motion.div> </PrefetchLink>)
</PrefetchLink>) })}
})} </div>)
</div>
</>)
}) satisfies FC<Props> }) satisfies FC<Props>
+10 -31
View File
@@ -6,21 +6,19 @@ import { DndContext,
useSensor, useSensor,
useSensors } from '@dnd-kit/core' useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges } from '@dnd-kit/modifiers' import { restrictToWindowEdges } from '@dnd-kit/modifiers'
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch' import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -132,10 +130,7 @@ const changeCategory = async (
tagId: number, tagId: number,
category: Category, category: Category,
): Promise<void> => { ): Promise<void> => {
await axios.patch ( await apiPatch (`/tags/${ tagId }`, { category })
`${ API_BASE_URL }/tags/${ tagId }`,
{ category },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
} }
@@ -170,12 +165,7 @@ export default (({ post }: Props) => {
if (!(post)) if (!(post))
return return
const res = await axios.get ( setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
`${ API_BASE_URL }/posts/${ post.id }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Post
setTags (buildTagByCategory (data))
} }
const onDragEnd = async (e: DragEndEvent) => { const onDragEnd = async (e: DragEndEvent) => {
@@ -216,16 +206,9 @@ export default (({ post }: Props) => {
return return
if (fromParentId != null) if (fromParentId != null)
{ await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
await axios.post ( await apiPost (`/tags/${ parentId }/children/${ childId }`, { })
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
{ },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await reloadTags () await reloadTags ()
toast ({ toast ({
@@ -245,11 +228,7 @@ export default (({ post }: Props) => {
await changeCategory (childId, cat) await changeCategory (childId, cat)
if (fromParentId != null) if (fromParentId != null)
{ await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId) const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
@@ -358,9 +337,9 @@ export default (({ post }: Props) => {
<>耕作者: </> <>耕作者: </>
{post.uploadedUser {post.uploadedUser
? ( ? (
<Link to={`/users/${ post.uploadedUser.id }`}> <PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'} {post.uploadedUser.name || '名もなきニジラー'}
</Link>) </PrefetchLink>)
: 'bot操作'} : 'bot操作'}
</li> </li>
*/} */}
@@ -389,7 +368,7 @@ export default (({ post }: Props) => {
</>)} </>)}
</li> </li>
<li> <li>
<Link to={`/posts/changes?id=${ post.id }`}></Link> <PrefetchLink to={`/posts/changes?id=${ post.id }`}></PrefetchLink>
</li> </li>
</ul> </ul>
</div>)} </div>)}
+23 -18
View File
@@ -1,29 +1,34 @@
import axios from 'axios'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { API_BASE_URL } from '@/config'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react' import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
type CommonProps = { tag: Tag type CommonProps = {
nestLevel?: number tag: Tag
withWiki?: boolean nestLevel?: number
withCount?: boolean withWiki?: boolean
prefetch?: boolean } withCount?: boolean
prefetch?: boolean }
type PropsWithLink = type PropsWithLink =
CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>> & CommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink = type PropsWithoutLink =
CommonProps & { linkFlg: false } & Partial<HTMLAttributes<HTMLSpanElement>> & CommonProps
& { linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type Props = PropsWithLink | PropsWithoutLink type Props =
| PropsWithLink
| PropsWithoutLink
export default (({ tag, export default (({ tag,
@@ -46,7 +51,7 @@ export default (({ tag,
try try
{ {
await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`) await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true) setHavingWiki (true)
} }
catch catch
@@ -76,17 +81,17 @@ export default (({ tag,
<span className="mr-1"> <span className="mr-1">
{havingWiki {havingWiki
? ( ? (
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`} <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}> className={linkClass}>
? ?
</Link>) </PrefetchLink>)
: ( : (
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`} <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite] className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}> title={`${ tag.name } Wiki が存在しません.`}>
! !
</Link>)} </PrefetchLink>)}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
@@ -110,12 +115,12 @@ export default (({ tag,
{...props}> {...props}>
{tag.name} {tag.name}
</PrefetchLink> </PrefetchLink>
: <Link : <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass} className={linkClass}
{...props}> {...props}>
{tag.name} {tag.name}
</Link>) </PrefetchLink>)
: ( : (
<span className={spanClass} <span className={spanClass}
{...props}> {...props}>
+6 -9
View File
@@ -1,13 +1,11 @@
import axios from 'axios' import { useEffect, useState } from 'react'
import toCamel from 'camelcase-keys'
import React, { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { API_BASE_URL } from '@/config' import { apiGet } from '@/lib/api'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
import type { FC } from 'react' import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -21,7 +19,7 @@ export default (() => {
const [suggestions, setSuggestions] = useState<Tag[]> ([]) const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => { const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setSearch (ev.target.value) setSearch (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1) const q = ev.target.value.trim ().split (' ').at (-1)
@@ -31,14 +29,13 @@ export default (() => {
return return
} }
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } }) const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
const data = toCamel (res.data, { deep: true }) as Tag[]
setSuggestions (data.filter (t => t.postCount > 0)) setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0) if (suggestions.length > 0)
setSuggestionsVsbl (true) setSuggestionsVsbl (true)
} }
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key) switch (ev.key)
{ {
case 'ArrowDown': case 'ArrowDown':
+4 -5
View File
@@ -1,4 +1,3 @@
import axios from 'axios'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -7,8 +6,8 @@ import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch' import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -77,10 +76,10 @@ export default (({ posts, onClick }: Props) => {
void ((async () => { void ((async () => {
try try
{ {
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, const data = await apiGet<Post> ('/posts/random',
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } }) match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`) navigate (`/posts/${ data.id }`)
} }
catch catch
{ {
+19 -22
View File
@@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query'
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 { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@@ -6,6 +7,7 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser' import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags' import { fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki' import { fetchWikiPage } from '@/lib/wiki'
@@ -44,11 +46,26 @@ export default (({ user }: Props) => {
visible: false }) visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [postCount, setPostCount] = useState<number | null> (null)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const effectiveTitle = wikiPage?.title ?? ''
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
const postCount = tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
const wikiTitle = location.pathname.split ('/')[2] const wikiTitle = location.pathname.split ('/')[2] ?? ''
const menu: Menu = [ const menu: Menu = [
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
@@ -113,26 +130,6 @@ export default (({ user }: Props) => {
location.pathname.startsWith (item.base || item.to)))) location.pathname.startsWith (item.base || item.to))))
}, [location]) }, [location])
useEffect (() => {
if (!(wikiId))
return
const fetchPostCount = async () => {
try
{
const wikiPage = await fetchWikiPage (String (wikiId ?? ''))
const tag = await fetchTagByName (wikiPage.title)
setPostCount (tag.postCount)
}
catch
{
setPostCount (0)
}
}
fetchPostCount ()
}, [wikiId])
return ( return (
<> <>
<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]
+3 -4
View File
@@ -1,6 +1,5 @@
import { Link } from 'react-router-dom'
import Separator from '@/components/MenuSeparator' import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -24,9 +23,9 @@ export default (({ user, sp }: Props) => {
return ( return (
<> <>
{sp && <Separator/>} {sp && <Separator/>}
<Link to="/users/settings" <PrefetchLink to="/users/settings"
className={className}> className={className}>
{user.name || '名もなきニジラー'} {user.name || '名もなきニジラー'}
</Link> </PrefetchLink>
</>) </>)
}) satisfies FC<Props> }) satisfies FC<Props>
+11 -24
View File
@@ -1,20 +1,18 @@
import axios from 'axios' import { useQuery } from '@tanstack/react-query'
import toCamel from 'camelcase-keys' import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { Link } from 'react-router-dom'
import remarkGFM from 'remark-gfm' import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle'
import { API_BASE_URL } from '@/config' import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
import type { FC } from 'react' import type { FC } from 'react'
import type { Components } from 'react-markdown' import type { Components } from 'react-markdown'
import type { WikiPage } from '@/types'
type Props = { title: string type Props = { title: string
body?: string } body?: string }
@@ -24,7 +22,7 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
a: (({ href, children }) => ( a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e)) ['/', '.'].some (e => href?.startsWith (e))
? <Link to={href!}>{children}</Link> ? <PrefetchLink to={href!}>{children}</PrefetchLink>
: ( : (
<a href={href} <a href={href}
target="_blank" target="_blank"
@@ -34,26 +32,15 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
export default (({ title, body }: Props) => { export default (({ title, body }: Props) => {
const [pageNames, setPageNames] = useState<string[]> ([]) const { data } = useQuery ({
enabled: Boolean (body),
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
const remarkPlugins = useMemo ( const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
useEffect (() => {
void (async () => {
try
{
const res = await axios.get (`${ API_BASE_URL }/wiki`)
const data: WikiPage[] = toCamel (res.data as any, { deep: true })
setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length))
}
catch
{
setPageNames ([])
}
}) ()
}, [])
return ( return (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
@@ -1,5 +1,3 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -8,7 +6,7 @@ import { Dialog,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config' import { apiPost } from '@/lib/api'
import type { User } from '@/types' import type { User } from '@/types'
@@ -26,12 +24,12 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
try try
{ {
const res = await axios.post (`${ API_BASE_URL }/users/verify`, { code: inputCode }) const data = await apiPost<{ valid: boolean; user: User }> (
const data = res.data as { valid: boolean; user: any } '/users/verify', { code: inputCode })
if (data.valid) if (data.valid)
{ {
localStorage.setItem ('user_code', inputCode) localStorage.setItem ('user_code', inputCode)
setUser (toCamel (data.user, { deep: true })) setUser (data.user)
toast ({ title: '引継ぎ成功!' }) toast ({ title: '引継ぎ成功!' })
onVisibleChange (false) onVisibleChange (false)
} }
@@ -1,11 +1,9 @@
import axios from 'axios'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config' import { apiPost } from '@/lib/api'
import type { User } from '@/types' import type { User } from '@/types'
@@ -23,10 +21,8 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.')))
return return
const res = await axios.post (`${ API_BASE_URL }/users/code/renew`, { }, { headers: { const data = await apiPost<{ code: string }> ('/users/code/renew', { },
'Content-Type': 'multipart/form-data', { headers: { 'Content-Type': 'multipart/form-data' } })
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as { code: string }
if (data.code) if (data.code)
{ {
localStorage.setItem ('user_code', data.code) localStorage.setItem ('user_code', data.code)
+12 -2
View File
@@ -3,9 +3,12 @@ import toCamel from 'camelcase-keys'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'
import type { AxiosError, AxiosRequestConfig } from 'axios'
type Opt = { type Opt = {
params?: Record<string, unknown> params?: AxiosRequestConfig['params']
headers?: Record<string, string> } headers?: Record<string, string>
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL }) const client = axios.create ({ baseURL: API_BASE_URL })
@@ -23,6 +26,8 @@ const apiP = async <T> (
opt?: Opt, opt?: Opt,
): Promise<T> => { ): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt)) const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as any, { deep: true }) as T return toCamel (res.data as any, { deep: true }) as T
} }
@@ -32,6 +37,8 @@ export const apiGet = async <T> (
opt?: Opt, opt?: Opt,
): Promise<T> => { ): Promise<T> => {
const res = await client.get (path, withUserCode (opt)) const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as any, { deep: true }) as T return toCamel (res.data as any, { deep: true }) as T
} }
@@ -63,3 +70,6 @@ export const apiDelete = async (
): Promise<void> => { ): Promise<void> => {
await client.delete (path, withUserCode (opt)) await client.delete (path, withUserCode (opt))
} }
export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err)
+14 -3
View File
@@ -1,6 +1,6 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types' import type { Post, PostTagChange } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
@@ -13,8 +13,8 @@ export const fetchPosts = async (
): Promise<{ ): Promise<{
posts: Post[] posts: Post[]
count: number count: number
nextCursor: string }> => await apiGet ('/posts', { nextCursor: string }> =>
params: { await apiGet ('/posts', { params: {
tags, tags,
match, match,
...(page && { page }), ...(page && { page }),
@@ -25,6 +25,17 @@ export const fetchPosts = async (
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
export const fetchPostChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number },
): Promise<{
changes: PostTagChange[]
count: number }> =>
await apiGet ('/posts/changes', { params: { ...(id && { id }), page, limit } })
export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {
await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`)
} }
+84 -10
View File
@@ -1,12 +1,69 @@
import { QueryClient } from '@tanstack/react-query' 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, fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage,
fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title')
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const title = url.searchParams.get ('title') ?? ''
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title }),
queryFn: () => fetchWikiPages ({ title }) })
}
const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const m = mWiki (url.pathname)
if (!(m))
return
const title = decodeURIComponent (m.params.title)
const version = url.searchParams.get ('version') || undefined
const wikiPage = await qc.fetchQuery ({
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
if (wikiPage)
{
const effectiveId = String (wikiPage.id ?? '')
await qc.prefetchQuery ({
queryKey: wikiKeys.show (effectiveId, { }),
queryFn: () => fetchWikiPage (effectiveId, { } ) })
if (wikiPage.body)
{
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
}
}
const effectiveTitle = wikiPage?.title ?? title
await qc.prefetchQuery ({
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
if (version)
return
const p = { tags: effectiveTitle, match: 'all', page: 1, limit: 8 } as const
await qc.prefetchQuery ({
queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) })
}
const prefetchPostsIndex: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url) => {
@@ -14,6 +71,7 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const m = 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 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: postsKeys.index ({ tags, match: m, page, limit }), queryKey: postsKeys.index ({ tags, match: m, page, limit }),
queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) queryFn: () => fetchPosts ({ tags, match: m, page, limit }) })
@@ -26,24 +84,40 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
return return
const { id } = m.params const { id } = m.params
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.show (id), queryKey: postsKeys.show (id),
queryFn: () => fetchPost (id) }) queryFn: () => fetchPost (id) })
} }
export const routePrefetchers: { const prefetchPostChanges: Prefetcher = async (qc, url) => {
test: (u: URL) => boolean const id = url.searchParams.get ('id')
run: Prefetcher }[] = [ const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
await qc.prefetchQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) })
}
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 => (!(['/posts/new', '/posts/changes'].includes (u.pathname))
&& Boolean (mPost (u.pathname))),
run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow }]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
const u = new URL (urlLike, location.origin) const u = new URL (urlLike, location.origin)
const jobs = routePrefetchers.filter (r => r.test (u)).map (r => r.run (qc, u)) const r = routePrefetchers.find (x => x.test (u))
if (jobs.length === 0) if (!(r))
return return
await r.run (qc, u)
await Promise.all (jobs)
} }
+10 -3
View File
@@ -3,8 +3,15 @@ export const postsKeys = {
index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) =>
['posts', 'index', p] as const, ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const, show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const } related: (id: string) => ['related', id] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['posts', 'changes', p] as const }
export const tagsKeys = {
root: ['tags'] as const,
show: (name: string) => ['tags', name] as const }
export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
show: (title: string, p: { version: string }) => ['wiki', title, p] as const } index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
+10 -2
View File
@@ -3,5 +3,13 @@ import { apiGet } from '@/lib/api'
import type { Tag } from '@/types' import type { Tag } from '@/types'
export const fetchTagByName = async (name: string): Promise<Tag> => export const fetchTagByName = async (name: string): Promise<Tag | null> => {
await apiGet (`/tags/name/${ name }`) try
{
return await apiGet (`/tags/name/${ name }`)
}
catch
{
return null
}
}
+21 -4
View File
@@ -3,12 +3,29 @@ import { apiGet } from '@/lib/api'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
export const fetchWikiPage = async (id: string): Promise<WikiPage> => export const fetchWikiPages = async (
await apiGet (`/wiki/${ id }`) { title }: { title?: string },
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async (
id: string,
{ version }: { version?: string },
): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async ( export const fetchWikiPageByTitle = async (
title: string, title: string,
{ version }: { version?: string }, { version }: { version?: string },
): Promise<WikiPage> => ): Promise<WikiPage | null> => {
await apiGet (`/wiki/title/${ title }`, { params: version ? { version } : { } }) try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
}
catch
{
return null
}
}
+59 -61
View File
@@ -91,67 +91,65 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600') : 'bg-gray-500 hover:bg-gray-600')
return ( return (
<> <div className="md:flex md:flex-1">
<div className="md:flex md:flex-1"> <Helmet>
<Helmet> {(post?.thumbnail || post?.thumbnailBase) && (
{(post?.thumbnail || post?.thumbnailBase) && ( <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 ?? null}/> <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>
</>)
<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>)
}) satisfies FC<Props> }) satisfies FC<Props>
+63 -70
View File
@@ -1,24 +1,20 @@
import axios from 'axios' import { useQuery } from '@tanstack/react-query'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { Link, useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
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 { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostTagChange } from '@/types'
export default (() => { export default (() => {
const [changes, setChanges] = useState<PostTagChange[]> ([])
const [totalPages, setTotalPages] = useState<number> (0)
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
@@ -28,17 +24,11 @@ export default (() => {
// 投稿列の結合で使用 // 投稿列の結合で使用
let rowsCnt: number let rowsCnt: number
useEffect (() => { const { data, isLoading: loading } = useQuery ({
void (async () => { queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }),
const res = await axios.get (`${ API_BASE_URL }/posts/changes`, queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) })
{ params: { ...(id && { id }), page, limit } }) const changes = data?.changes ?? []
const data = toCamel (res.data as any, { deep: true }) as { const totalPages = data ? Math.ceil (data.count / limit) : 0
changes: PostTagChange[]
count: number }
setChanges (data.changes)
setTotalPages (Math.ceil (data.count / limit))
}) ()
}, [id, page, limit])
return ( return (
<MainArea> <MainArea>
@@ -48,57 +38,60 @@ export default (() => {
<PageTitle> <PageTitle>
{id && <>: 稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>} {id && <>: 稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>}
</PageTitle> </PageTitle>
<table className="table-auto w-full border-collapse"> {loading ? 'Loading...' : (
<thead> <>
<tr> <table className="table-auto w-full border-collapse">
<th className="p-2 text-left">稿</th> <thead>
<th className="p-2 text-left"></th> <tr>
<th className="p-2 text-left"></th> <th className="p-2 text-left">稿</th>
</tr> <th className="p-2 text-left"></th>
</thead> <th className="p-2 text-left"></th>
<tbody> </tr>
{changes.map ((change, i) => { </thead>
let withPost = i === 0 || change.post.id !== changes[i - 1].post.id <tbody>
if (withPost) {changes.map ((change, i) => {
{ let withPost = i === 0 || change.post.id !== changes[i - 1].post.id
rowsCnt = 1 if (withPost)
for (let j = i + 1; {
(j < changes.length rowsCnt = 1
&& change.post.id === changes[j].post.id); for (let j = i + 1;
++j) (j < changes.length
++rowsCnt && change.post.id === changes[j].post.id);
} ++j)
return ( ++rowsCnt
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> }
{withPost && ( return (
<td className="align-top" rowSpan={rowsCnt}> <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
<Link to={`/posts/${ change.post.id }`}> {withPost && (
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined} <td className="align-top" rowSpan={rowsCnt}>
alt={change.post.title || change.post.url} <PrefetchLink to={`/posts/${ change.post.id }`}>
title={change.post.title || change.post.url || undefined} <img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
className="w-40"/> alt={change.post.title || change.post.url}
</Link> title={change.post.title || change.post.url || undefined}
</td>)} className="w-40"/>
<td> </PrefetchLink>
<TagLink tag={change.tag} withWiki={false} withCount={false}/> </td>)}
{`${ change.changeType === 'add' ? '追加' : '削除' }`} <td>
</td> <TagLink tag={change.tag} withWiki={false} withCount={false}/>
<td> {`${ change.changeType === 'add' ? '記載' : '消除' }`}
{change.user ? ( </td>
<Link to={`/users/${ change.user.id }`}> <td>
{change.user.name} {change.user ? (
</Link>) : 'bot 操作'} <PrefetchLink to={`/users/${ change.user.id }`}>
<br/> {change.user.name}
{change.timestamp} </PrefetchLink>) : 'bot 操作'}
</td> <br/>
</tr>) {change.timestamp}
})} </td>
</tbody> </tr>)
</table> })}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages}/> <Pagination page={page} totalPages={totalPages}/>
</>)}
</MainArea>) </MainArea>)
}) satisfies FC }) satisfies FC
+6 -14
View File
@@ -1,4 +1,3 @@
import axios from 'axios'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -11,7 +10,8 @@ import PageTitle from '@/components/common/PageTitle'
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 { apiGet, apiPost } from '@/lib/api'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
@@ -55,9 +55,7 @@ export default (({ user }: Props) => {
try try
{ {
await axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate ('/posts') navigate ('/posts')
} }
@@ -91,10 +89,7 @@ export default (({ user }: Props) => {
const fetchTitle = async () => { const fetchTitle = async () => {
setTitle ('') setTitle ('')
setTitleLoading (true) setTitleLoading (true)
const res = await axios.get (`${ API_BASE_URL }/preview/title`, { const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
params: { url },
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as { title: string }
setTitle (data.title || '') setTitle (data.title || '')
setTitleLoading (false) setTitleLoading (false)
} }
@@ -105,11 +100,8 @@ export default (({ user }: Props) => {
setThumbnailLoading (true) setThumbnailLoading (true)
if (thumbnailPreview) if (thumbnailPreview)
URL.revokeObjectURL (thumbnailPreview) URL.revokeObjectURL (thumbnailPreview)
const res = await axios.get (`${ API_BASE_URL }/preview/thumbnail`, { const data = await apiGet<Blob> ('/preview/thumbnail',
params: { url }, { params: { url }, responseType: 'blob' })
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' },
responseType: 'blob' })
const data = res.data as Blob
const imageURL = URL.createObjectURL (data) const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL) setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data], setThumbnailFile (new File ([data],
+6 -11
View File
@@ -1,5 +1,3 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
@@ -8,7 +6,8 @@ import SectionTitle from '@/components/common/SectionTitle'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
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 { apiGet, apiPut } from '@/lib/api'
import type { NicoTag, Tag, User } from '@/types' import type { NicoTag, Tag, User } from '@/types'
@@ -29,10 +28,8 @@ export default ({ user }: Props) => {
const loadMore = async (withCursor: boolean) => { const loadMore = async (withCursor: boolean) => {
setLoading (true) setLoading (true)
const res = await axios.get (`${ API_BASE_URL }/tags/nico`, { const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> (
params: { ...(withCursor ? { cursor } : { }) } }) '/tags/nico', { params: withCursor ? { cursor } : { } })
const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[]
nextCursor: string }
setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags])
setCursor (data.nextCursor) setCursor (data.nextCursor)
@@ -53,10 +50,8 @@ export default ({ user }: Props) => {
const formData = new FormData const formData = new FormData
formData.append ('tags', rawTags[id]) formData.append ('tags', rawTags[id])
const res = await axios.put (`${ API_BASE_URL }/tags/nico/${ id }`, formData, { headers: { const data = await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
'Content-Type': 'multipart/form-data', { headers: { 'Content-Type': 'multipart/form-data' } })
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Tag[]
setNicoTags (nicoTags => { setNicoTags (nicoTags => {
nicoTags.find (t => t.id === id)!.linkedTags = data nicoTags.find (t => t.id === id)!.linkedTags = data
return [...nicoTags] return [...nicoTags]
+5 -6
View File
@@ -1,4 +1,3 @@
import axios from 'axios'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
@@ -10,7 +9,8 @@ import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue'
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 { apiPut } from '@/lib/api'
import type { User } from '@/types' import type { User } from '@/types'
@@ -32,10 +32,9 @@ export default ({ user, setUser }: Props) => {
try try
{ {
const res = await axios.put (`${ API_BASE_URL }/users/${ user.id }`, formData, { const data = await apiPut<User> (
headers: { 'Content-Type': 'multipart/form-data', `/users/${ user.id }`, formData,
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
const data = res.data as User
setUser (user => ({ ...user, ...data })) setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' }) toast ({ title: '設定を更新しました.' })
} }
+43 -61
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -12,10 +13,11 @@ import MainArea from '@/components/layout/MainArea'
import { 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 { fetchPosts } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags' import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
import type { Post, Tag, WikiPage } from '@/types' import type { Tag } from '@/types'
export default () => { export default () => {
@@ -25,76 +27,57 @@ export default () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const defaultTag = { name: title, category: 'general' } as Tag const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
const [posts, setPosts] = useState<Post[]> ([])
const [tag, setTag] = useState (defaultTag)
const [wikiPage, setWikiPage] = useState<WikiPage | null | undefined> (undefined)
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const version = query.get ('version') const version = query.get ('version') || undefined
const { data: wikiPage, isLoading: loading } = useQuery ({
enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
const effectiveTitle = wikiPage?.title ?? title
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }),
queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) })
const posts = data?.posts || []
useEffect (() => { useEffect (() => {
if (/^\d+$/.test (title)) if (!(wikiPage))
{ return
void (async () => {
setWikiPage (undefined)
try
{
const data = await fetchWikiPage (title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
}
catch
{
;
}
}) ()
return WikiIdBus.set (wikiPage.id)
}
void (async () => { if (wikiPage.title !== title)
setWikiPage (undefined) navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true })
try
{ return () => WikiIdBus.set (null)
const data = await fetchWikiPageByTitle (title, version ? { version } : { }) }, [wikiPage, title, navigate])
if (data.title !== title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) useEffect (() => {
setWikiPage (data) if (!(/^\d+$/.test (title)))
WikiIdBus.set (data.id) return
}
catch
{
setWikiPage (null)
}
}) ()
setPosts ([])
void (async () => { void (async () => {
try try
{ {
const data = await fetchPosts ({ tags: title, match: 'all', limit: 8 }) const data = await fetchWikiPage (title, { })
setPosts (data.posts) navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
} }
catch catch
{ {
; ;
} }
}) () }) ()
}, [title, navigate])
void (async () => {
try
{
setTag (await fetchTagByName (title))
}
catch
{
setTag (defaultTag)
}
}) ()
return () => WikiIdBus.set (null)
}, [title, location.search])
return ( return (
<MainArea> <MainArea>
@@ -104,7 +87,8 @@ export default () => {
</Helmet> </Helmet>
{(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 ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}>
&lt; &lt;
@@ -119,15 +103,13 @@ export default () => {
</div>)} </div>)}
<PageTitle> <PageTitle>
<TagLink tag={tag} <TagLink tag={tag ?? defaultTag}
withWiki={false} withWiki={false}
withCount={false} withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
</PageTitle> </PageTitle>
<div className="prose mx-auto p-4"> <div className="prose mx-auto p-4">
{wikiPage === undefined {loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
? 'Loading...'
: <WikiBody title={title} body={wikiPage?.body}/>}
</div> </div>
{(!(version) && posts.length > 0) && ( {(!(version) && posts.length > 0) && (
+3 -5
View File
@@ -1,12 +1,11 @@
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 { useLocation, useParams } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
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 { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { WikiPageDiff } from '@/types' import type { WikiPageDiff } from '@/types'
@@ -25,8 +24,7 @@ export default () => {
useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }/diff`, { params: { from, to } }) setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } }))
setDiff (toCamel (res.data as any, { deep: true }) as WikiPageDiff)
}) () }) ()
}, []) }, [])
+12 -7
View File
@@ -1,4 +1,4 @@
import axios from 'axios' import { useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
@@ -7,7 +7,9 @@ import { useParams, useNavigate } from 'react-router-dom'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
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 { apiGet, apiPut } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import 'react-markdown-editor-lite/lib/index.css'
@@ -29,6 +31,8 @@ export default (({ user }: Props) => {
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient ()
const [body, setBody] = useState ('') const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true) const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
@@ -40,9 +44,11 @@ export default (({ user }: Props) => {
try try
{ {
await axios.put (`${ API_BASE_URL }/wiki/${ id }`, formData, { headers: { await apiPut (`/wiki/${ id }`, formData,
'Content-Type': 'multipart/form-data', { headers: { 'Content-Type': 'multipart/form-data' } })
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) qc.setQueryData (wikiKeys.show (title, { }),
(prev: WikiPage) => ({ ...prev, title, body }))
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`) navigate (`/wiki/${ title }`)
} }
@@ -55,8 +61,7 @@ export default (({ user }: Props) => {
useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
setLoading (true) setLoading (true)
const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) const data = await apiGet<WikiPage> (`/wiki/${ id }`)
const data = res.data as WikiPage
setTitle (data.title) setTitle (data.title)
setBody (data.body) setBody (data.body)
setLoading (false) setLoading (false)
+13 -13
View File
@@ -1,11 +1,11 @@
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 } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
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 { apiGet } from '@/lib/api'
import type { WikiPageChange } from '@/types' import type { WikiPageChange } from '@/types'
@@ -19,9 +19,7 @@ export default () => {
useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
const res = await axios.get (`${ API_BASE_URL }/wiki/changes`, setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } }))
{ params: { ...(id ? { id } : { }) } })
setChanges (toCamel (res.data as any, { deep: true }) as WikiPageChange[])
}) () }) ()
}, [location.search]) }, [location.search])
@@ -44,22 +42,24 @@ export default () => {
<tr key={change.revisionId}> <tr key={change.revisionId}>
<td> <td>
{change.pred != null && ( {change.pred != null && (
<Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> <PrefetchLink
to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
</Link>)} </PrefetchLink>)}
</td> </td>
<td className="p-2"> <td className="p-2">
<Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> <PrefetchLink
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
{change.wikiPage.title} {change.wikiPage.title}
</Link> </PrefetchLink>
</td> </td>
<td className="p-2"> <td className="p-2">
{change.pred == null ? '新規' : '更新'} {change.pred == null ? '新規' : '更新'}
</td> </td>
<td className="p-2"> <td className="p-2">
<Link to={`/users/${ change.user.id }`}> <PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name} {change.user.name}
</Link> </PrefetchLink>
<br/> <br/>
{change.timestamp} {change.timestamp}
</td> </td>
+4 -6
View File
@@ -1,4 +1,3 @@
import axios from 'axios'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
@@ -7,7 +6,8 @@ import { useLocation, useNavigate } from 'react-router-dom'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
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 { apiPost } from '@/lib/api'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import 'react-markdown-editor-lite/lib/index.css'
@@ -39,10 +39,8 @@ export default ({ user }: Props) => {
try try
{ {
const res = await axios.post (`${ API_BASE_URL }/wiki`, formData, { headers: { const data = await apiPost<WikiPage> ('/wiki', formData,
'Content-Type': 'multipart/form-data', { headers: { 'Content-Type': 'multipart/form-data' } })
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as WikiPage
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`) navigate (`/wiki/${ data.title }`)
} }
+10 -10
View File
@@ -1,12 +1,13 @@
import axios from 'axios' import { useEffect, useState } from 'react'
import toCamel from 'camelcase-keys'
import React, { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
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 { apiGet } from '@/lib/api'
import type { FormEvent } from 'react'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
@@ -17,11 +18,10 @@ export default () => {
const [results, setResults] = useState<WikiPage[]> ([]) const [results, setResults] = useState<WikiPage[]> ([])
const search = async () => { const search = async () => {
const res = await axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) setResults (await apiGet ('/wiki', { params: { title } }))
setResults (toCamel (res.data as any, { deep: true }) as WikiPage[])
} }
const handleSearch = (ev: React.FormEvent) => { const handleSearch = (ev: FormEvent) => {
ev.preventDefault () ev.preventDefault ()
search () search ()
} }
@@ -78,9 +78,9 @@ export default () => {
{results.map (page => ( {results.map (page => (
<tr key={page.id}> <tr key={page.id}>
<td className="p-2"> <td className="p-2">
<Link to={`/wiki/${ encodeURIComponent (page.title) }`}> <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title} {page.title}
</Link> </PrefetchLink>
</td> </td>
<td className="p-2 text-gray-100 text-sm"> <td className="p-2 text-gray-100 text-sm">
{page.updatedAt} {page.updatedAt}