Merge remote-tracking branch 'origin/main' into feature/140
このコミットが含まれているのは:
@@ -0,0 +1,93 @@
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { CSSProperties, FC, MutableRefObject } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type Props = {
|
||||
tag: Tag
|
||||
nestLevel: number
|
||||
pathKey: string
|
||||
parentTagId?: number
|
||||
suppressClickRef: MutableRefObject<boolean> }
|
||||
|
||||
|
||||
export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => {
|
||||
const dndId = `tag-node:${ pathKey }`
|
||||
|
||||
const downPosRef = useRef<{ x: number; y: number } | null> (null)
|
||||
const armedRef = useRef (false)
|
||||
|
||||
const armEatNextClick = () => {
|
||||
if (armedRef.current)
|
||||
return
|
||||
|
||||
armedRef.current = true
|
||||
suppressClickRef.current = true
|
||||
|
||||
const handler = (ev: MouseEvent) => {
|
||||
ev.preventDefault ()
|
||||
ev.stopPropagation ()
|
||||
armedRef.current = false
|
||||
suppressClickRef.current = false
|
||||
}
|
||||
|
||||
addEventListener ('click', handler, { capture: true, once: true })
|
||||
}
|
||||
|
||||
const { attributes,
|
||||
listeners,
|
||||
setNodeRef: setDragRef,
|
||||
transform,
|
||||
isDragging: dragging } = useDraggable ({ id: dndId,
|
||||
data: { kind: 'tag',
|
||||
tagId: tag.id,
|
||||
parentTagId } })
|
||||
|
||||
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
||||
id: dndId,
|
||||
data: { kind: 'tag', tagId: tag.id } })
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||
visibility: dragging ? 'hidden' : 'visible' }
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDownCapture={e => {
|
||||
downPosRef.current = { x: e.clientX, y: e.clientY }
|
||||
}}
|
||||
onPointerMoveCapture={e => {
|
||||
const p = downPosRef.current
|
||||
if (!(p))
|
||||
return
|
||||
const dx = e.clientX - p.x
|
||||
const dy = e.clientY - p.y
|
||||
if (dx * dx + dy * dy >= 9)
|
||||
armEatNextClick ()
|
||||
}}
|
||||
onPointerUpCapture={() => {
|
||||
downPosRef.current = null
|
||||
}}
|
||||
onClickCapture={e => {
|
||||
if (suppressClickRef.current)
|
||||
{
|
||||
e.preventDefault ()
|
||||
e.stopPropagation ()
|
||||
}
|
||||
}}
|
||||
ref={node => {
|
||||
setDragRef (node)
|
||||
setDropRef (node)
|
||||
}}
|
||||
style={style}
|
||||
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</div>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
@@ -10,7 +10,23 @@ import { API_BASE_URL } from '@/config'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
import type { Post, Tag } from '@/types'
|
||||
|
||||
|
||||
const tagsToStr = (tags: Tag[]): string => {
|
||||
const result: Tag[] = []
|
||||
|
||||
const walk = (tag: Tag) => {
|
||||
const { children, ...rest } = tag
|
||||
result.push (rest)
|
||||
children?.forEach (walk)
|
||||
}
|
||||
|
||||
tags.filter (t => t.category !== 'nico').forEach (walk)
|
||||
|
||||
return [...(new Set (result.map (t => t.name)))].join (' ')
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post
|
||||
onSave: (newPost: Post) => void }
|
||||
@@ -22,10 +38,7 @@ export default (({ post, onSave }: Props) => {
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||||
useState<string | null> (post.originalCreatedFrom)
|
||||
const [title, setTitle] = useState (post.title)
|
||||
const [tags, setTags] = useState<string> (post.tags
|
||||
.filter (t => t.category !== 'nico')
|
||||
.map (t => t.name)
|
||||
.join (' '))
|
||||
const [tags, setTags] = useState<string> ('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const res = await axios.put (
|
||||
@@ -43,6 +56,10 @@ export default (({ post, onSave }: Props) => {
|
||||
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
setTags(tagsToStr (post.tags))
|
||||
}, [post])
|
||||
|
||||
return (
|
||||
<div className="max-w-xl pt-2 space-y-4">
|
||||
{/* タイトル */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
@@ -39,10 +40,28 @@ export default (({ post }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [framed, setFramed] = useState (false)
|
||||
|
||||
return (
|
||||
<a href={post.url} target="_blank">
|
||||
<img src={post.thumbnailBase || post.thumbnail}
|
||||
alt={post.url}
|
||||
className="mb-4 w-full"/>
|
||||
</a>)
|
||||
<>
|
||||
{framed
|
||||
? (
|
||||
<iframe
|
||||
src={post.url}
|
||||
title={post.title || post.url}
|
||||
width={640}
|
||||
height={360}/>)
|
||||
: (
|
||||
<div>
|
||||
<a href="#" onClick={e => {
|
||||
e.preventDefault ()
|
||||
setFramed (confirm ('未確認の外部ページを表示します。\n'
|
||||
+ '悪意のあるスクリプトが実行される可能性があります。\n'
|
||||
+ '表示しますか?'))
|
||||
return
|
||||
}}>
|
||||
外部ページを表示
|
||||
</a>
|
||||
</div>)}
|
||||
</>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -60,7 +60,8 @@ export default (({ tags, setTags }: Props) => {
|
||||
const { start, end, token } = getTokenAt (v, pos)
|
||||
setBounds ({ start, end })
|
||||
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } })
|
||||
setSuggestions (toCamel (res.data as any, { deep: true }) as Tag[])
|
||||
const data = toCamel (res.data as any, { deep: true }) as Tag[]
|
||||
setSuggestions (data.filter (t => t.postCount > 0))
|
||||
setSuggestionsVsbl (suggestions.length > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,280 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useDroppable,
|
||||
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 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 type { FC } from 'react'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||
|
||||
import type { Category, Post, Tag } from '@/types'
|
||||
|
||||
type TagByCategory = { [key in Category]: Tag[] }
|
||||
|
||||
|
||||
const renderTagTree = (
|
||||
tag: Tag,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
suppressClickRef: MutableRefObject<boolean>,
|
||||
parentTagId?: number,
|
||||
): ReactNode[] => {
|
||||
const key = `${ path }-${ tag.id }`
|
||||
|
||||
const self = (
|
||||
<motion.li
|
||||
key={key}
|
||||
layout
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
className="mb-1">
|
||||
<DraggableDroppableTagRow
|
||||
tag={tag}
|
||||
nestLevel={nestLevel}
|
||||
pathKey={key}
|
||||
parentTagId={parentTagId}
|
||||
suppressClickRef={suppressClickRef}/>
|
||||
</motion.li>)
|
||||
|
||||
return [
|
||||
self,
|
||||
...((tag.children
|
||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||
.flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id)))
|
||||
?? [])]
|
||||
}
|
||||
|
||||
|
||||
const isDescendant = (
|
||||
root: Tag,
|
||||
targetId: number,
|
||||
): boolean => {
|
||||
if (!(root.children))
|
||||
return false
|
||||
|
||||
for (const c of root.children)
|
||||
{
|
||||
if (c.id === targetId)
|
||||
return true
|
||||
if (isDescendant (c, targetId))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
const findTag = (
|
||||
byCat: TagByCategory,
|
||||
id: number,
|
||||
): Tag | undefined => {
|
||||
const walk = (nodes: Tag[]): Tag | undefined => {
|
||||
for (const t of nodes)
|
||||
{
|
||||
if (t.id === id)
|
||||
return t
|
||||
|
||||
const found = t.children ? walk (t.children) : undefined
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const cat of Object.keys (byCat) as (keyof typeof byCat)[])
|
||||
{
|
||||
const found = walk (byCat[cat] ?? [])
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
const buildTagByCategory = (post: Post): TagByCategory => {
|
||||
const tagsTmp = { } as TagByCategory
|
||||
|
||||
for (const tag of post.tags)
|
||||
{
|
||||
if (!(tag.category in tagsTmp))
|
||||
tagsTmp[tag.category] = []
|
||||
|
||||
tagsTmp[tag.category].push (tag)
|
||||
}
|
||||
|
||||
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
|
||||
tagsTmp[cat].sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||
|
||||
return tagsTmp
|
||||
}
|
||||
|
||||
|
||||
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') ?? '' } })
|
||||
}
|
||||
|
||||
|
||||
const DropSlot = ({ cat }: { cat: Category }) => {
|
||||
const { setNodeRef, isOver: over } = useDroppable ({
|
||||
id: `slot:${ cat }`,
|
||||
data: { kind: 'slot', cat } })
|
||||
|
||||
return (
|
||||
<li ref={setNodeRef} className="h-1">
|
||||
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
|
||||
</li>)
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post | null }
|
||||
|
||||
|
||||
export default (({ post }: Props) => {
|
||||
const [activeTagId, setActiveTagId] = useState<number | null> (null)
|
||||
const [dragging, setDragging] = useState (false)
|
||||
const [saving, setSaving] = useState (false)
|
||||
const [tags, setTags] = useState ({ } as TagByCategory)
|
||||
|
||||
const suppressClickRef = useRef (false)
|
||||
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
||||
|
||||
const reloadTags = async (): Promise<void> => {
|
||||
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))
|
||||
}
|
||||
|
||||
const onDragEnd = async (e: DragEndEvent) => {
|
||||
if (saving)
|
||||
return
|
||||
|
||||
const activeKind = e.active.data.current?.kind
|
||||
if (activeKind !== 'tag')
|
||||
return
|
||||
|
||||
const childId: number | undefined = e.active.data.current?.tagId
|
||||
const fromParentId: number | undefined = e.active.data.current?.parentTagId
|
||||
|
||||
const overKind = e.over?.data.current?.kind
|
||||
|
||||
if (childId == null || !(overKind))
|
||||
return
|
||||
|
||||
const child = findTag (tags, childId)
|
||||
|
||||
try
|
||||
{
|
||||
setSaving (true)
|
||||
|
||||
switch (overKind)
|
||||
{
|
||||
case 'tag':
|
||||
{
|
||||
const parentId: number | undefined = e.over?.data.current?.tagId
|
||||
if (parentId == null || childId === parentId)
|
||||
return
|
||||
|
||||
const parent = findTag (tags, parentId)
|
||||
|
||||
if (!(child)
|
||||
|| !(parent)
|
||||
|| isDescendant (child, parentId))
|
||||
return
|
||||
|
||||
if (fromParentId != null)
|
||||
{
|
||||
await axios.delete (
|
||||
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
|
||||
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
|
||||
}
|
||||
|
||||
await axios.post (
|
||||
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
|
||||
{ },
|
||||
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
|
||||
|
||||
await reloadTags ()
|
||||
toast ({
|
||||
title: '上位タグ対応追加',
|
||||
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'slot':
|
||||
{
|
||||
const cat: Category | undefined = e.over?.data.current?.cat
|
||||
if (!(cat) || !(child))
|
||||
return
|
||||
|
||||
if (child.category !== cat)
|
||||
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') ?? '' } })
|
||||
}
|
||||
|
||||
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
|
||||
|
||||
await reloadTags ()
|
||||
toast ({
|
||||
title: '上位タグ対応解除',
|
||||
description: (
|
||||
fromParent
|
||||
? `《${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
|
||||
: `《${ child.name }》のカテゴリを変更しました.`) })
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSaving (false)
|
||||
}
|
||||
}
|
||||
|
||||
const categoryNames: Record<Category, string> = {
|
||||
deerjikist: 'ニジラー',
|
||||
meme: '原作・ネタ元・ミーム等',
|
||||
@@ -50,58 +306,103 @@ export default (({ post }: Props) => {
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<TagSearch/>
|
||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
||||
<div className="my-3" key={cat}>
|
||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||
<ul>
|
||||
{tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<TagLink tag={tag}/>
|
||||
</li>))}
|
||||
</ul>
|
||||
</div>))}
|
||||
{post && (
|
||||
<div>
|
||||
<SectionTitle>情報</SectionTitle>
|
||||
<ul>
|
||||
<li>Id.: {post.id}</li>
|
||||
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
|
||||
{/*
|
||||
<li>
|
||||
<>耕作者: </>
|
||||
{post.uploadedUser
|
||||
? (
|
||||
<Link to={`/users/${ post.uploadedUser.id }`}>
|
||||
{post.uploadedUser.name || '名もなきニジラー'}
|
||||
</Link>)
|
||||
: 'bot操作'}
|
||||
</li>
|
||||
*/}
|
||||
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
||||
<li>
|
||||
<>リンク: </>
|
||||
<a
|
||||
className="break-all"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow">
|
||||
{post.url}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
||||
<>オリジナルの投稿日時: </>
|
||||
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
||||
? '不明'
|
||||
: (
|
||||
<>
|
||||
{post.originalCreatedFrom
|
||||
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
||||
{post.originalCreatedBefore
|
||||
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
||||
</>)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>)}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={e => {
|
||||
if (e.active.data.current?.kind === 'tag')
|
||||
setActiveTagId (e.active.data.current?.tagId ?? null)
|
||||
setDragging (true)
|
||||
suppressClickRef.current = true
|
||||
document.body.style.userSelect = 'none'
|
||||
getSelection?.()?.removeAllRanges?.()
|
||||
addEventListener ('click', e => {
|
||||
e.preventDefault ()
|
||||
e.stopPropagation ()
|
||||
suppressClickRef.current = false
|
||||
}, { capture: true, once: true })
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
setActiveTagId (null)
|
||||
setDragging (false)
|
||||
document.body.style.userSelect = ''
|
||||
suppressClickRef.current = false
|
||||
}}
|
||||
onDragEnd={async e => {
|
||||
setActiveTagId (null)
|
||||
setDragging (false)
|
||||
await onDragEnd (e)
|
||||
document.body.style.userSelect = ''
|
||||
}}
|
||||
modifiers={[restrictToWindowEdges]}>
|
||||
<motion.div key={post?.id ?? 0} layout>
|
||||
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
|
||||
<motion.div layout className="my-3" key={cat}>
|
||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||
|
||||
<motion.ul layout>
|
||||
<AnimatePresence initial={false}>
|
||||
{(tags[cat] ?? []).flatMap (tag => (
|
||||
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
|
||||
<DropSlot cat={cat}/>
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>))}
|
||||
{post && (
|
||||
<div>
|
||||
<SectionTitle>情報</SectionTitle>
|
||||
<ul>
|
||||
<li>Id.: {post.id}</li>
|
||||
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
|
||||
{/*
|
||||
<li>
|
||||
<>耕作者: </>
|
||||
{post.uploadedUser
|
||||
? (
|
||||
<Link to={`/users/${ post.uploadedUser.id }`}>
|
||||
{post.uploadedUser.name || '名もなきニジラー'}
|
||||
</Link>)
|
||||
: 'bot操作'}
|
||||
</li>
|
||||
*/}
|
||||
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
||||
<li>
|
||||
<>リンク: </>
|
||||
<a
|
||||
className="break-all"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow">
|
||||
{post.url}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
||||
<>オリジナルの投稿日時: </>
|
||||
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
||||
? '不明'
|
||||
: (
|
||||
<>
|
||||
{post.originalCreatedFrom
|
||||
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
||||
{post.originalCreatedBefore
|
||||
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
||||
</>)}
|
||||
</li>
|
||||
<li>
|
||||
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>)}
|
||||
</motion.div>
|
||||
|
||||
<DragOverlay adjustScale={false}>
|
||||
<div className="pointer-events-none">
|
||||
{activeTagId != null && (() => {
|
||||
const tag = findTag (tags, activeTagId)
|
||||
return tag && <TagLink tag={tag}/>
|
||||
}) ()}
|
||||
</div>
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</SidebarComponent>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -9,6 +12,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type CommonProps = { tag: Tag
|
||||
nestLevel?: number
|
||||
withWiki?: boolean
|
||||
withCount?: boolean
|
||||
prefetch?: boolean }
|
||||
@@ -23,11 +27,41 @@ type Props = PropsWithLink | PropsWithoutLink
|
||||
|
||||
|
||||
export default (({ tag,
|
||||
nestLevel = 0,
|
||||
linkFlg = true,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
prefetch = false,
|
||||
...props }: Props) => {
|
||||
const [havingWiki, setHavingWiki] = useState (true)
|
||||
|
||||
const wikiExists = async (tag: Tag) => {
|
||||
if ('hasWiki' in tag)
|
||||
{
|
||||
setHavingWiki (tag.hasWiki)
|
||||
return
|
||||
}
|
||||
|
||||
const tagName = (tag as Tag).name
|
||||
|
||||
try
|
||||
{
|
||||
await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`)
|
||||
setHavingWiki (true)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setHavingWiki (false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (!(linkFlg) || !(withWiki))
|
||||
return
|
||||
|
||||
wikiExists (tag)
|
||||
}, [tag.name, linkFlg, withWiki])
|
||||
|
||||
const spanClass = cn (
|
||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||
@@ -40,11 +74,33 @@ export default (({ tag,
|
||||
<>
|
||||
{(linkFlg && withWiki) && (
|
||||
<span className="mr-1">
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>
|
||||
{havingWiki
|
||||
? (
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>)
|
||||
: (
|
||||
<Link 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>)}
|
||||
</span>)}
|
||||
{nestLevel > 0 && (
|
||||
<span
|
||||
className="ml-1 mr-1"
|
||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||
↳
|
||||
</span>)}
|
||||
{tag.matchedAlias != null && (
|
||||
<>
|
||||
<span className={spanClass} {...props}>
|
||||
{tag.matchedAlias}
|
||||
</span>
|
||||
<> → </>
|
||||
</>)}
|
||||
{linkFlg
|
||||
? (
|
||||
prefetch
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
@@ -31,8 +32,8 @@ export default (() => {
|
||||
}
|
||||
|
||||
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
|
||||
const data = res.data as Tag[]
|
||||
setSuggestions (data)
|
||||
const data = toCamel (res.data, { deep: true }) as Tag[]
|
||||
setSuggestions (data.filter (t => t.postCount > 0))
|
||||
if (suggestions.length > 0)
|
||||
setSuggestionsVsbl (true)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import TagLink from '@/components/TagLink'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
@@ -22,8 +23,7 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => {
|
||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||
onMouseDown={() => onSelect (tag)}>
|
||||
{tag.name}
|
||||
{<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>}
|
||||
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
||||
</li>))}
|
||||
</ul>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
@@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
@@ -59,47 +59,71 @@ export default (({ posts, onClick }: Props) => {
|
||||
setTags (tagsTmp)
|
||||
}, [posts])
|
||||
|
||||
const TagBlock = (
|
||||
<>
|
||||
<SectionTitle>タグ</SectionTitle>
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<TagLink tag={tag} prefetch onClick={onClick}/>
|
||||
</li>))) : [])}
|
||||
</ul>
|
||||
<SectionTitle>関聯</SectionTitle>
|
||||
{posts.length > 0 && (
|
||||
<a href="#"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
void ((async () => {
|
||||
try
|
||||
{
|
||||
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
|
||||
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
|
||||
match: (anyFlg ? 'any' : 'all') } })
|
||||
navigate (`/posts/${ (data as Post).id }`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ())
|
||||
}}>
|
||||
ランダム
|
||||
</a>)}
|
||||
</>)
|
||||
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<TagSearch/>
|
||||
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
|
||||
<SectionTitle>タグ</SectionTitle>
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ?
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<TagLink tag={tag} prefetch onClick={onClick}/>
|
||||
</li>)) : [])}
|
||||
</ul>
|
||||
<SectionTitle>関聯</SectionTitle>
|
||||
{posts.length > 0 && (
|
||||
<a href="#"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
void ((async () => {
|
||||
try
|
||||
{
|
||||
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
|
||||
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
|
||||
match: (anyFlg ? 'any' : 'all') } })
|
||||
navigate (`/posts/${ (data as Post).id }`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ())
|
||||
}}>
|
||||
ランダム
|
||||
</a>)}
|
||||
|
||||
<div className="hidden md:block mt-4">
|
||||
{TagBlock}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{tagsVsbl && (
|
||||
<motion.div
|
||||
key="sptags"
|
||||
className="md:hidden mt-4"
|
||||
variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0 },
|
||||
visible: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto'} }}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{TagBlock}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
|
||||
<a href="#"
|
||||
className="md:hidden block my-2 text-center text-sm
|
||||
text-gray-500 hover:text-gray-400
|
||||
dark:text-gray-300 dark:hover:text-gray-100"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setTagsVsbl (!(tagsVsbl))
|
||||
setTagsVsbl (v => !(v))
|
||||
}}>
|
||||
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
@@ -19,6 +20,28 @@ type Props = { user: User | null }
|
||||
export default (({ user }: Props) => {
|
||||
const location = useLocation ()
|
||||
|
||||
const dirRef = useRef<(-1) | 1> (1)
|
||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const measure = () => {
|
||||
const nav = navRef.current
|
||||
const el = itemsRef.current[activeIdx]
|
||||
if (!(nav) || !(el) || activeIdx < 0)
|
||||
return
|
||||
|
||||
const navRect = nav.getBoundingClientRect ()
|
||||
const elRect = el.getBoundingClientRect ()
|
||||
|
||||
setHl ({ left: elRect.left - navRect.left,
|
||||
width: elRect.width,
|
||||
visible: true })
|
||||
}
|
||||
|
||||
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||
left: 0,
|
||||
width: 0,
|
||||
visible: false })
|
||||
const [menuOpen, setMenuOpen] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [postCount, setPostCount] = useState<number | null> (null)
|
||||
@@ -30,6 +53,7 @@ export default (({ user }: Props) => {
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '投稿追加', to: '/posts/new' },
|
||||
{ name: '耕作履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'タグ一覧', to: '/tags', visible: false },
|
||||
@@ -52,6 +76,32 @@ export default (({ user }: Props) => {
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
||||
|
||||
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
if (activeIdx !== prevActiveIdxRef.current)
|
||||
{
|
||||
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
|
||||
prevActiveIdxRef.current = activeIdx
|
||||
}
|
||||
|
||||
const dir = dirRef.current
|
||||
|
||||
useLayoutEffect (() => {
|
||||
if (activeIdx < 0)
|
||||
return
|
||||
|
||||
const raf = requestAnimationFrame (measure)
|
||||
const onResize = () => requestAnimationFrame (measure)
|
||||
|
||||
addEventListener ('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame (raf)
|
||||
removeEventListener ('resize', onResize)
|
||||
}
|
||||
}, [activeIdx])
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
return () => unsubscribe ()
|
||||
@@ -97,16 +147,26 @@ export default (({ user }: Props) => {
|
||||
ぼざクリ タグ広場
|
||||
</Link>
|
||||
|
||||
{menu.map ((item, i) => (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
className={cn ('hidden md:flex h-full items-center',
|
||||
(location.pathname.startsWith (item.base || item.to)
|
||||
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold'
|
||||
: 'px-2'))}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<div ref={navRef} className="relative hidden md:flex h-full items-center">
|
||||
<div aria-hidden
|
||||
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
|
||||
'bg-yellow-200 dark:bg-red-950',
|
||||
'transition-[transform,width] duration-200 ease-out')}
|
||||
style={{ width: hl.width,
|
||||
transform: `translate(${ hl.left }px, -50%)`,
|
||||
opacity: hl.visible ? 1 : 0 }}/>
|
||||
|
||||
{menu.map ((item, i) => (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
ref={el => {
|
||||
itemsRef.current[i] = el
|
||||
}}
|
||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
</Link>))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TopNavUser user={user}/>
|
||||
@@ -123,49 +183,101 @@ export default (({ user }: Props) => {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||
items-center w-full min-h-[40px] px-3">
|
||||
{menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => 'component' in item ? item.component : (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</Link>))}
|
||||
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||
items-center w-full min-h-[40px] overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="absolute inset-0 flex items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{(menu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
||||
: (
|
||||
<Link key={`l-${ i }`}
|
||||
to={item.to}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</Link>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden',
|
||||
'bg-yellow-200 dark:bg-red-975 items-start')}>
|
||||
<Separator/>
|
||||
{menu.map ((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<Link to={i === openItemIdx ? item.to : '#'}
|
||||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||
((i === openItemIdx)
|
||||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||||
onClick={ev => {
|
||||
if (i !== openItemIdx)
|
||||
{
|
||||
ev.preventDefault ()
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
</Link>
|
||||
{i === openItemIdx && (
|
||||
item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => 'component' in subItem ? subItem.component : (
|
||||
<Link key={j}
|
||||
to={subItem.to}
|
||||
className="w-full min-h-[36px] flex items-center pl-12
|
||||
bg-yellow-50 dark:bg-red-950">
|
||||
{subItem.name}
|
||||
</Link>)))}
|
||||
</Fragment>))}
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
key="spmenu"
|
||||
className={cn ('flex flex-col md:hidden',
|
||||
'bg-yellow-200 dark:bg-red-975 items-start')}
|
||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0 },
|
||||
open: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto' } }}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
<Separator/>
|
||||
{menu.map ((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<Link to={i === openItemIdx ? item.to : '#'}
|
||||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||
((i === openItemIdx)
|
||||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||||
onClick={ev => {
|
||||
if (i !== openItemIdx)
|
||||
{
|
||||
ev.preventDefault ()
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{i === openItemIdx && (
|
||||
<motion.div
|
||||
key={`sp-sub-${ i }`}
|
||||
className="w-full bg-yellow-50 dark:bg-red-950"
|
||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0,
|
||||
opacity: 0 },
|
||||
open: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto',
|
||||
opacity: 1 } }}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => (
|
||||
'component' in subItem
|
||||
? (
|
||||
<Fragment key={`sp-c-${ i }-${ j }`}>
|
||||
{subItem.component}
|
||||
</Fragment>)
|
||||
: (
|
||||
<Link key={`sp-l-${ i }-${ j }`}
|
||||
to={subItem.to}
|
||||
className="w-full min-h-[36px] flex items-center pl-12">
|
||||
{subItem.name}
|
||||
</Link>)))}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</Fragment>))}
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Link } from 'react-router-dom'
|
||||
import remarkGFM from 'remark-gfm'
|
||||
@@ -8,6 +8,7 @@ import remarkGFM from 'remark-gfm'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { Components } from 'react-markdown'
|
||||
@@ -34,17 +35,16 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
|
||||
|
||||
export default (({ title, body }: Props) => {
|
||||
const [pageNames, setPageNames] = useState<string[]> ([])
|
||||
const [realBody, setRealBody] = useState<string> ('')
|
||||
|
||||
const remarkPlugins = useMemo (
|
||||
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(body))
|
||||
return
|
||||
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const res = await axios.get (`${ API_BASE_URL }/wiki`)
|
||||
const data = toCamel (res.data as any, { deep: true }) as WikiPage[]
|
||||
const data: WikiPage[] = toCamel (res.data as any, { deep: true })
|
||||
setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length))
|
||||
}
|
||||
catch
|
||||
@@ -54,52 +54,8 @@ export default (({ title, body }: Props) => {
|
||||
}) ()
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
setRealBody ('')
|
||||
}, [body])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(body))
|
||||
return
|
||||
|
||||
const matchIndices = (target: string, keyword: string) => {
|
||||
const indices: number[] = []
|
||||
let pos = 0
|
||||
let idx
|
||||
while ((idx = target.indexOf (keyword, pos)) >= 0)
|
||||
{
|
||||
indices.push (idx)
|
||||
pos = idx + keyword.length
|
||||
}
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
const linkIndices = (text: string, names: string[]): [string, [number, number]][] => {
|
||||
const result: [string, [number, number]][] = []
|
||||
|
||||
names.forEach (name => {
|
||||
matchIndices (text, name).forEach (idx => {
|
||||
const start = idx
|
||||
const end = idx + name.length
|
||||
const overlaps = result.some (([, [st, ed]]) => start < ed && end > st)
|
||||
if (!(overlaps))
|
||||
result.push ([name, [start, end]])
|
||||
})
|
||||
})
|
||||
|
||||
return result.sort (([, [a]], [, [b]]) => b - a)
|
||||
}
|
||||
|
||||
setRealBody (
|
||||
linkIndices (body, pageNames).reduce ((acc, [name, [start, end]]) => (
|
||||
acc.slice (0, start)
|
||||
+ `[${ name }](/wiki/${ encodeURIComponent (name) })`
|
||||
+ acc.slice (end)), body))
|
||||
}, [body, pageNames])
|
||||
|
||||
return (
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={[remarkGFM]}>
|
||||
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
|
||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
</ReactMarkdown>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = { page: number
|
||||
totalPages: number
|
||||
siblingCount?: number }
|
||||
|
||||
|
||||
const range = (start: number, end: number): number[] =>
|
||||
[...Array (end - start + 1).keys ()].map (i => start + i)
|
||||
|
||||
|
||||
const getPages = (
|
||||
page: number,
|
||||
total: number,
|
||||
siblingCount: number,
|
||||
): (number | '…')[] => {
|
||||
if (total <= 1)
|
||||
return [1]
|
||||
|
||||
const first = 1
|
||||
const last = total
|
||||
|
||||
const left = Math.max (page - siblingCount, first)
|
||||
const right = Math.min (page + siblingCount, last)
|
||||
|
||||
const pages: (number | '…')[] = []
|
||||
|
||||
pages.push (first)
|
||||
|
||||
if (left > first + 1)
|
||||
pages.push ('…')
|
||||
|
||||
const midStart = Math.max (left, first + 1)
|
||||
const midEnd = Math.min (right, last - 1)
|
||||
pages.push (...range (midStart, midEnd))
|
||||
|
||||
if (right < last - 1)
|
||||
pages.push ('…')
|
||||
|
||||
if (last !== first)
|
||||
pages.push (last)
|
||||
|
||||
return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1])
|
||||
}
|
||||
|
||||
|
||||
export default (({ page, totalPages, siblingCount = 4 }) => {
|
||||
const location = useLocation ()
|
||||
|
||||
const buildTo = (p: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
qs.set ('page', String (p))
|
||||
return `${ location.pathname }?${ qs.toString () }`
|
||||
}
|
||||
|
||||
const pages = getPages (page, totalPages, siblingCount)
|
||||
|
||||
return (
|
||||
<nav className="mt-4 flex justify-center" aria-label="Pagination">
|
||||
<div className="flex items-center gap-2">
|
||||
{(page > 1)
|
||||
? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link>
|
||||
: <span aria-hidden><</span>}
|
||||
|
||||
{pages.map ((p, idx) => (
|
||||
(p === '…')
|
||||
? <span key={`dots-${ idx }`}>…</span>
|
||||
: ((p === page)
|
||||
? <span key={p} className="font-bold" aria-current="page">{p}</span>
|
||||
: <Link key={p} to={buildTo (p)}>{p}</Link>)))}
|
||||
|
||||
{(page < totalPages)
|
||||
? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link>
|
||||
: <span aria-hidden>></span>}
|
||||
</div>
|
||||
</nav>)
|
||||
}) satisfies FC<Props>
|
||||
新しい課題から参照
ユーザをブロックする