Compare commits

...

27 Commits

Author SHA1 Message Date
  みてるぞ 4421a4226c Merge branch 'main' into feature/140 2 days ago
  みてるぞ 810528e285 #140 5 days ago
  みてるぞ c7d575517f Merge remote-tracking branch 'origin/main' into feature/140 5 days ago
  みてるぞ 26bd40d804 #140 5 days ago
  みてるぞ d3cc8805fb #140 6 days ago
  みてるぞ 4c5668653f #140 6 days ago
  みてるぞ 9b4b2a74e0 #140 6 days ago
  みてるぞ 4f09abf104 #140 1 week ago
  みてるぞ 874559dc6c Merge remote-tracking branch 'origin/main' into feature/140 1 week ago
  みてるぞ a751add415 #140 1 week ago
  みてるぞ a39b86c456 #140 1 week ago
  みてるぞ 57f28e4e5e #140 1 week ago
  みてるぞ f56f16ad2f #140 1 week ago
  みてるぞ 0a9f96743e #140 1 week ago
  みてるぞ 0994d54f0f #140 1 week ago
  みてるぞ d120df3493 #140 1 week ago
  みてるぞ 914dc43889 #140 1 week ago
  みてるぞ 4c559b30fe #140 1 week ago
  みてるぞ e0446e35ff #140 1 week ago
  みてるぞ 8cc2a88e7c #140 1 week ago
  みてるぞ 145d93679c Merge remote-tracking branch 'origin/main' into feature/140 1 week ago
  みてるぞ 51c20a42c7 Merge remote-tracking branch 'origin/main' into feature/140 2 weeks ago
  みてるぞ 7df51fb34b #140 ぼちぼち 2 months ago
  みてるぞ 4e00ec40ab Merge remote-tracking branch 'origin/main' into feature/140 2 months ago
  みてるぞ 214c91e3bf #140 4 months ago
  みてるぞ 5cbe21b5d7 #140 4 months ago
  みてるぞ d5d7e0e22b #140 4 months ago
30 changed files with 519 additions and 490 deletions
Unified View
  1. +7
    -11
      frontend/src/App.tsx
  2. +5
    -10
      frontend/src/components/PostEditForm.tsx
  3. +2
    -5
      frontend/src/components/PostFormTagsArea.tsx
  4. +45
    -48
      frontend/src/components/PostList.tsx
  5. +10
    -31
      frontend/src/components/TagDetailSidebar.tsx
  6. +23
    -18
      frontend/src/components/TagLink.tsx
  7. +6
    -9
      frontend/src/components/TagSearch.tsx
  8. +4
    -5
      frontend/src/components/TagSidebar.tsx
  9. +19
    -22
      frontend/src/components/TopNav.tsx
  10. +3
    -4
      frontend/src/components/TopNavUser.tsx
  11. +11
    -24
      frontend/src/components/WikiBody.tsx
  12. +4
    -6
      frontend/src/components/users/InheritDialogue.tsx
  13. +3
    -7
      frontend/src/components/users/UserCodeDialogue.tsx
  14. +12
    -2
      frontend/src/lib/api.ts
  15. +14
    -3
      frontend/src/lib/posts.ts
  16. +84
    -10
      frontend/src/lib/prefetchers.ts
  17. +10
    -3
      frontend/src/lib/queryKeys.ts
  18. +10
    -2
      frontend/src/lib/tags.ts
  19. +21
    -4
      frontend/src/lib/wiki.ts
  20. +60
    -62
      frontend/src/pages/posts/PostDetailPage.tsx
  21. +63
    -70
      frontend/src/pages/posts/PostHistoryPage.tsx
  22. +6
    -14
      frontend/src/pages/posts/PostNewPage.tsx
  23. +6
    -11
      frontend/src/pages/tags/NicoTagListPage.tsx
  24. +5
    -6
      frontend/src/pages/users/SettingPage.tsx
  25. +44
    -62
      frontend/src/pages/wiki/WikiDetailPage.tsx
  26. +3
    -5
      frontend/src/pages/wiki/WikiDiffPage.tsx
  27. +12
    -7
      frontend/src/pages/wiki/WikiEditPage.tsx
  28. +13
    -13
      frontend/src/pages/wiki/WikiHistoryPage.tsx
  29. +4
    -6
      frontend/src/pages/wiki/WikiNewPage.tsx
  30. +10
    -10
      frontend/src/pages/wiki/WikiSearchPage.tsx

+ 7
- 11
frontend/src/App.tsx 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 = res.data as { code: string; user: any }
const data = await apiPost<{ code: string; user: User }> ('/users')
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 = res.data as { valid: boolean, user: any }
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
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))
setStatus (err.status ?? 200)
if (isApiError (err))
setStatus (err.response?.status ?? 200)
} }
}) () }) ()
} }


+ 5
- 10
frontend/src/components/PostEditForm.tsx 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 (
`${ API_BASE_URL }/posts/${ post.id }`,
{ title, tags,
original_created_from: originalCreatedFrom,
const data = await apiPut<Post> (
`/posts/${ post.id }`,
{ title, tags, original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore }, original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Post
{ headers: { 'Content-Type': 'multipart/form-data' } })
onSave ({ ...post, onSave ({ ...post,
title: data.title, title: data.title,
tags: data.tags, tags: data.tags,


+ 2
- 5
frontend/src/components/PostFormTagsArea.tsx 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 = toCamel (res.data as any, { deep: true }) as Tag[]
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token } })
setSuggestions (data.filter (t => t.postCount > 0)) setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0) setSuggestionsVsbl (suggestions.length > 0)
} }


+ 45
- 48
frontend/src/components/PostList.tsx 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">
{posts.map ((post, i) => {
const id2 = `page-${ post.id }`
const layoutId = id2
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const sharedId = `page-${ post.id }`
const layoutId = sharedId


return (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId: `page-${ post.id }` }}
onClick={e => {
const sharedId = `page-${ post.id }`
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
<motion.div
ref={cardRef}
layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => {
if (cardRef.current)
{
cardRef.current.style.position = 'relative'
cardRef.current.style.zIndex = '9999'
}
}}
onLayoutAnimationComplete={() => {
if (cardRef.current)
{
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}
}}
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>
</PrefetchLink>)
})}
</div>
</>)
return (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
<motion.div
ref={cardRef}
layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => {
if (!(cardRef.current))
return

cardRef.current.style.position = 'relative'
cardRef.current.style.zIndex = '9999'
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return

cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>
</PrefetchLink>)
})}
</div>)
}) satisfies FC<Props> }) satisfies FC<Props>

+ 10
- 31
frontend/src/components/TagDetailSidebar.tsx 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 (
`${ API_BASE_URL }/tags/${ tagId }`,
{ category },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await apiPatch (`/tags/${ tagId }`, { category })
} }




@@ -170,12 +165,7 @@ export default (({ post }: Props) => {
if (!(post)) if (!(post))
return return


const res = await axios.get (
`${ 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))
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
} }


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 axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)


await axios.post (
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
{ },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await apiPost (`/tags/${ parentId }/children/${ childId }`, { })


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 axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)


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
frontend/src/components/TagLink.tsx 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
nestLevel?: number
withWiki?: boolean
withCount?: boolean
prefetch?: boolean }
type CommonProps = {
tag: Tag
nestLevel?: number
withWiki?: 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
frontend/src/components/TagSearch.tsx View File

@@ -1,13 +1,11 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import React, { useEffect, useState } from 'react'
import { 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 = toCamel (res.data, { deep: true }) as Tag[]
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
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
frontend/src/components/TagSidebar.tsx 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`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
const data = await apiGet<Post> ('/posts/random',
{ 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
frontend/src/components/TopNav.tsx 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
frontend/src/components/TopNavUser.tsx 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
frontend/src/components/WikiBody.tsx View File

@@ -1,20 +1,18 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useMemo } 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) })。`}


+ 4
- 6
frontend/src/components/users/InheritDialogue.tsx View File

@@ -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 = res.data as { valid: boolean; user: any }
const data = await apiPost<{ valid: boolean; user: User }> (
'/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)
} }


+ 3
- 7
frontend/src/components/users/UserCodeDialogue.tsx View File

@@ -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: {
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as { code: string }
const data = await apiPost<{ code: string }> ('/users/code/renew', { },
{ headers: { 'Content-Type': 'multipart/form-data' } })
if (data.code) if (data.code)
{ {
localStorage.setItem ('user_code', data.code) localStorage.setItem ('user_code', data.code)


+ 12
- 2
frontend/src/lib/api.ts 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>
headers?: Record<string, string> }
params?: AxiosRequestConfig['params']
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
frontend/src/lib/posts.ts 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', {
params: {
nextCursor: string }> =>
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
frontend/src/lib/prefetchers.ts 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 { postsKeys } from '@/lib/queryKeys'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
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: {
test: (u: URL) => boolean
run: Prefetcher }[] = [
const prefetchPostChanges: Prefetcher = async (qc, url) => {
const id = url.searchParams.get ('id')
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))
if (jobs.length === 0)
const r = routePrefetchers.find (x => x.test (u))
if (!(r))
return return

await Promise.all (jobs)
await r.run (qc, u)
} }

+ 10
- 3
frontend/src/lib/queryKeys.ts 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,
show: (title: string, p: { version: string }) => ['wiki', title, p] as const }
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }

+ 10
- 2
frontend/src/lib/tags.ts 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> =>
await apiGet (`/tags/name/${ name }`)
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
try
{
return await apiGet (`/tags/name/${ name }`)
}
catch
{
return null
}
}

+ 21
- 4
frontend/src/lib/wiki.ts 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> =>
await apiGet (`/wiki/${ id }`)
export const fetchWikiPages = async (
{ 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> =>
await apiGet (`/wiki/title/${ title }`, { params: version ? { version } : { } })
): Promise<WikiPage | null> => {
try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
}
catch
{
return null
}
}

+ 60
- 62
frontend/src/pages/posts/PostDetailPage.tsx 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">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>

<div className="hidden md:block">
<TagDetailSidebar post={post ?? null}/>
</div>

<MainArea className="relative">
{post
? (
<>
{(post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
overflow-hidden rounded-xl pointer-events-none z-50"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
<img src={post.thumbnail || post.thumbnailBase}
alt={post.title || post.url}
title={post.title || post.url || undefined}
className="object-cover w-full h-full"/>
</motion.div>)}

<PostEmbed post={post}/>
<Button onClick={() => changeViewedFlg.mutate ()}
disabled={changeViewedFlg.isPending}
className={cn ('text-white', viewedClass)}>
{post.viewed ? '閲覧済' : '未閲覧'}
</Button>
<TabGroup>
<Tab name="関聯">
{post.related.length > 0
? <PostList posts={post.related}/>
: 'まだないよ(笑)'}
</Tab>
{['admin', 'member'].some (r => user?.role === r) && (
<Tab name="編輯">
<PostEditForm
post={post}
onSave={newPost => {
qc.setQueryData (postsKeys.show (postId),
(prev: any) => newPost ?? prev)
qc.invalidateQueries ({ queryKey: postsKeys.root })
toast ({ description: '更新しました.' })
}}/>
</Tab>)}
</TabGroup>
</>)
: 'Loading...'}
</MainArea>

<div className="md:hidden">
<TagDetailSidebar post={post ?? null}/>
</div>
<div className="md:flex md:flex-1">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>

<div className="hidden md:block">
<TagDetailSidebar post={post ?? null}/>
</div> </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
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -1,24 +1,20 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
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 (() => {
void (async () => {
const res = await axios.get (`${ API_BASE_URL }/posts/changes`,
{ params: { ...(id && { id }), page, limit } })
const data = toCamel (res.data as any, { deep: true }) as {
changes: PostTagChange[]
count: number }
setChanges (data.changes)
setTotalPages (Math.ceil (data.count / limit))
}) ()
}, [id, page, limit])
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) })
const changes = data?.changes ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0


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">
<thead>
<tr>
<th className="p-2 text-left">投稿</th>
<th className="p-2 text-left">変更</th>
<th className="p-2 text-left">日時</th>
</tr>
</thead>
<tbody>
{changes.map ((change, i) => {
let withPost = i === 0 || change.post.id !== changes[i - 1].post.id
if (withPost)
{
rowsCnt = 1
for (let j = i + 1;
(j < changes.length
&& change.post.id === changes[j].post.id);
++j)
++rowsCnt
}
return (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
{withPost && (
<td className="align-top" rowSpan={rowsCnt}>
<Link to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
</Link>
</td>)}
<td>
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`を${ change.changeType === 'add' ? '追加' : '削除' }`}
</td>
<td>
{change.user ? (
<Link to={`/users/${ change.user.id }`}>
{change.user.name}
</Link>) : 'bot 操作'}
<br/>
{change.timestamp}
</td>
</tr>)
})}
</tbody>
</table>
{loading ? 'Loading...' : (
<>
<table className="table-auto w-full border-collapse">
<thead>
<tr>
<th className="p-2 text-left">投稿</th>
<th className="p-2 text-left">変更</th>
<th className="p-2 text-left">日時</th>
</tr>
</thead>
<tbody>
{changes.map ((change, i) => {
let withPost = i === 0 || change.post.id !== changes[i - 1].post.id
if (withPost)
{
rowsCnt = 1
for (let j = i + 1;
(j < changes.length
&& change.post.id === changes[j].post.id);
++j)
++rowsCnt
}
return (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
{withPost && (
<td className="align-top" rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
</PrefetchLink>
</td>)}
<td>
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`を${ change.changeType === 'add' ? '記載' : '消除' }`}
</td>
<td>
{change.user ? (
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}
</PrefetchLink>) : 'bot 操作'}
<br/>
{change.timestamp}
</td>
</tr>)
})}
</tbody>
</table>


<Pagination page={page} totalPages={totalPages}/>
<Pagination page={page} totalPages={totalPages}/>
</>)}
</MainArea>) </MainArea>)
}) satisfies FC }) satisfies FC

+ 6
- 14
frontend/src/pages/posts/PostNewPage.tsx 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: {
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
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`, {
params: { url },
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as { title: string }
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
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`, {
params: { url },
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' },
responseType: 'blob' })
const data = res.data as Blob
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: '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
frontend/src/pages/tags/NicoTagListPage.tsx 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`, {
params: { ...(withCursor ? { cursor } : { }) } })
const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[]
nextCursor: string }
const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> (
'/tags/nico', { params: withCursor ? { cursor } : { } })


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: {
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Tag[]
const data = await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
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
frontend/src/pages/users/SettingPage.tsx 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, {
headers: { 'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as User
const data = await apiPut<User> (
`/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (user => ({ ...user, ...data })) setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' }) toast ({ title: '設定を更新しました.' })
} }


+ 44
- 62
frontend/src/pages/wiki/WikiDetailPage.tsx 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 [posts, setPosts] = useState<Post[]> ([])
const [tag, setTag] = useState (defaultTag)
const [wikiPage, setWikiPage] = useState<WikiPage | null | undefined> (undefined)
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])


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))
{
void (async () => {
setWikiPage (undefined)
try
{
const data = await fetchWikiPage (title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
}
catch
{
;
}
}) ()

return
}
if (!(wikiPage))
return


void (async () => {
setWikiPage (undefined)
try
{
const data = await fetchWikiPageByTitle (title, version ? { version } : { })
if (data.title !== title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
setWikiPage (data)
WikiIdBus.set (data.id)
}
catch
{
setWikiPage (null)
}
}) ()
WikiIdBus.set (wikiPage.id)


setPosts ([])
void (async () => {
try
{
const data = await fetchPosts ({ tags: title, match: 'all', limit: 8 })
setPosts (data.posts)
}
catch
{
;
}
}) ()
if (wikiPage.title !== title)
navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true })

return () => WikiIdBus.set (null)
}, [wikiPage, title, navigate])

useEffect (() => {
if (!(/^\d+$/.test (title)))
return


void (async () => { void (async () => {
try try
{ {
setTag (await fetchTagByName (title))
const data = await fetchWikiPage (title, { })
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
} }
catch catch
{ {
setTag (defaultTag)
;
} }
}) () }) ()

return () => WikiIdBus.set (null)
}, [title, location.search])
}, [title, navigate])


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...'
: <WikiBody title={title} body={wikiPage?.body}/>}
{loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
</div> </div>


{(!(version) && posts.length > 0) && ( {(!(version) && posts.length > 0) && (


+ 3
- 5
frontend/src/pages/wiki/WikiDiffPage.tsx 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 (toCamel (res.data as any, { deep: true }) as WikiPageDiff)
setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } }))
}) () }) ()
}, []) }, [])




+ 12
- 7
frontend/src/pages/wiki/WikiEditPage.tsx 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: {
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await apiPut (`/wiki/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
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 = res.data as WikiPage
const data = await apiGet<WikiPage> (`/wiki/${ id }`)
setTitle (data.title) setTitle (data.title)
setBody (data.body) setBody (data.body)
setLoading (false) setLoading (false)


+ 13
- 13
frontend/src/pages/wiki/WikiHistoryPage.tsx 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`,
{ params: { ...(id ? { id } : { }) } })
setChanges (toCamel (res.data as any, { deep: true }) as WikiPageChange[])
setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } }))
}) () }) ()
}, [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
frontend/src/pages/wiki/WikiNewPage.tsx 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: {
'Content-Type': 'multipart/form-data',
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
const data = res.data as WikiPage
const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`) navigate (`/wiki/${ data.title }`)
} }


+ 10
- 10
frontend/src/pages/wiki/WikiSearchPage.tsx View File

@@ -1,12 +1,13 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import React, { useEffect, useState } from 'react'
import { 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 (toCamel (res.data as any, { deep: true }) as WikiPage[])
setResults (await apiGet ('/wiki', { params: { title } }))
} }


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}


Loading…
Cancel
Save