プリフェッチ実装(#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
+5 -10
View File
@@ -1,12 +1,10 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button'
import { API_BASE_URL } from '@/config'
import { apiPut } from '@/lib/api'
import type { FC } from 'react'
@@ -41,14 +39,11 @@ export default (({ post, onSave }: Props) => {
const [tags, setTags] = useState<string> ('')
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 },
{ 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,
title: data.title,
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 TagSearchBox from '@/components/TagSearchBox'
import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea'
import { API_BASE_URL } from '@/config'
import { apiGet } from '@/lib/api'
import type { FC, SyntheticEvent } from 'react'
@@ -59,8 +57,7 @@ export default (({ tags, setTags }: Props) => {
const recompute = async (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
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))
setSuggestionsVsbl (suggestions.length > 0)
}
+45 -48
View File
@@ -21,53 +21,50 @@ export default (({ posts, onClick }: Props) => {
const cardRef = useRef<HTMLDivElement> (null)
return (
<>
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const id2 = `page-${ post.id }`
const layoutId = id2
<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>
+10 -31
View File
@@ -6,21 +6,19 @@ import { DndContext,
useSensor,
useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -132,10 +130,7 @@ const changeCategory = async (
tagId: number,
category: Category,
): 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))
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) => {
@@ -216,16 +206,9 @@ export default (({ post }: Props) => {
return
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 ()
toast ({
@@ -245,11 +228,7 @@ export default (({ post }: Props) => {
await changeCategory (childId, cat)
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)
@@ -358,9 +337,9 @@ export default (({ post }: Props) => {
<>耕作者: </>
{post.uploadedUser
? (
<Link to={`/users/${ post.uploadedUser.id }`}>
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'}
</Link>)
</PrefetchLink>)
: 'bot操作'}
</li>
*/}
@@ -389,7 +368,7 @@ export default (({ post }: Props) => {
</>)}
</li>
<li>
<Link to={`/posts/changes?id=${ post.id }`}></Link>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}></PrefetchLink>
</li>
</ul>
</div>)}
+23 -18
View File
@@ -1,29 +1,34 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { API_BASE_URL } from '@/config'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
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 =
CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>>
& CommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
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,
@@ -46,7 +51,7 @@ export default (({ tag,
try
{
await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`)
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true)
}
catch
@@ -76,17 +81,17 @@ export default (({ tag,
<span className="mr-1">
{havingWiki
? (
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
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]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</Link>)}
</PrefetchLink>)}
</span>)}
{nestLevel > 0 && (
<span
@@ -110,12 +115,12 @@ export default (({ tag,
{...props}>
{tag.name}
</PrefetchLink>
: <Link
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</Link>)
</PrefetchLink>)
: (
<span className={spanClass}
{...props}>
+6 -9
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 { API_BASE_URL } from '@/config'
import { apiGet } from '@/lib/api'
import TagSearchBox from './TagSearchBox'
import type { FC } from 'react'
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
@@ -21,7 +19,7 @@ export default (() => {
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => {
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setSearch (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
@@ -31,14 +29,13 @@ export default (() => {
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))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
+4 -5
View File
@@ -1,4 +1,3 @@
import axios from 'axios'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -7,8 +6,8 @@ import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api'
import type { FC, MouseEvent } from 'react'
@@ -77,10 +76,10 @@ export default (({ posts, onClick }: Props) => {
void ((async () => {
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') } })
navigate (`/posts/${ (data as Post).id }`)
navigate (`/posts/${ data.id }`)
}
catch
{
+19 -22
View File
@@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
@@ -6,6 +7,7 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
@@ -44,11 +46,26 @@ export default (({ user }: Props) => {
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [postCount, setPostCount] = useState<number | null> (null)
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 wikiTitle = location.pathname.split ('/')[2]
const wikiTitle = location.pathname.split ('/')[2] ?? ''
const menu: Menu = [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
@@ -113,26 +130,6 @@ export default (({ user }: Props) => {
location.pathname.startsWith (item.base || item.to))))
}, [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 (
<>
<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 PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
@@ -24,9 +23,9 @@ export default (({ user, sp }: Props) => {
return (
<>
{sp && <Separator/>}
<Link to="/users/settings"
<PrefetchLink to="/users/settings"
className={className}>
{user.name || '名もなきニジラー'}
</Link>
</PrefetchLink>
</>)
}) satisfies FC<Props>
+11 -24
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 { Link } from 'react-router-dom'
import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
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 { fetchWikiPages } from '@/lib/wiki'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
import type { WikiPage } from '@/types'
type Props = { title: string
body?: string }
@@ -24,7 +22,7 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
? <Link to={href!}>{children}</Link>
? <PrefetchLink to={href!}>{children}</PrefetchLink>
: (
<a href={href}
target="_blank"
@@ -34,26 +32,15 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
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 (
() => [() => 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 (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
@@ -1,5 +1,3 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -8,7 +6,7 @@ import { Dialog,
DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config'
import { apiPost } from '@/lib/api'
import type { User } from '@/types'
@@ -26,12 +24,12 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
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)
{
localStorage.setItem ('user_code', inputCode)
setUser (toCamel (data.user, { deep: true }))
setUser (data.user)
toast ({ title: '引継ぎ成功!' })
onVisibleChange (false)
}
@@ -1,11 +1,9 @@
import axios from 'axios'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config'
import { apiPost } from '@/lib/api'
import type { User } from '@/types'
@@ -23,10 +21,8 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.')))
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)
{
localStorage.setItem ('user_code', data.code)