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

This commit is contained in:
2026-01-29 23:53:37 +09:00
110 changed files with 4918 additions and 820 deletions
+100
View File
@@ -9,6 +9,9 @@
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5",
@@ -18,6 +21,7 @@
"camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",
@@ -31,6 +35,7 @@
"react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -372,6 +377,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
@@ -3602,6 +3660,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5245,6 +5330,21 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+6 -1
View File
@@ -11,6 +11,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5",
@@ -20,6 +23,7 @@
"camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",
@@ -33,7 +37,8 @@
"react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",
"zustand": "^5.0.8"
"zustand": "^5.0.8",
"unist-util-visit-parents": "^6.0.1"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
+2 -3
View File
@@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`,
{ params: { ...(tagName && { tags: tagName,
match: 'all',
limit: '20' }) } })).data.posts
const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id)
const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data
const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name)
@@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
<div class="flex gap-4"><a href="#" class="font-bold">広場</a></div>
<div class="mt-2">
<div class="flex flex-wrap gap-6 p-4">
${ (await fetchPosts (tagName)).map (post => `
${ (await fetchPosts (tagName)).slice (0, 20).map (post => `
<a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
href="/posts/${ post.id }">
<img alt="${ post.title }"
@@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => `
fetchpriority="high"
decoding="async"
class="object-none w-full h-full"
src="${ post.url }" />
src="${ post.thumbnail }" />
</a>`).join ('') }
</div>
</div>
+2
View File
@@ -10,6 +10,7 @@ import { API_BASE_URL } from '@/config'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -89,6 +90,7 @@ export default (() => {
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
@@ -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>
+23 -6
View File
@@ -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">
{/* タイトル */}
+24 -5
View File
@@ -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>
+2 -1
View File
@@ -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)
}
+356 -55
View File
@@ -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>
+60 -4
View File
@@ -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
+3 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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>
+56 -32
View File
@@ -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>
+165 -53
View File
@@ -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>
+8 -52
View File
@@ -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="前のページ">&lt;</Link>
: <span aria-hidden>&lt;</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="次のページ">&gt;</Link>
: <span aria-hidden>&gt;</span>}
</div>
</nav>)
}) satisfies FC<Props>
+12
View File
@@ -96,3 +96,15 @@ button:focus-visible
background-color: #f9f9f9;
}
}
@keyframes wiki-blink
{
0%, 100% { color: #dc2626; }
50% { color: #2563eb; }
}
@keyframes wiki-blink-dark
{
0%, 100% { color: #f87171; }
50% { color: #60a5fa; }
}
+21 -8
View File
@@ -6,16 +6,29 @@ import { API_BASE_URL } from '@/config'
import type { Post } from '@/types'
export const fetchPosts = async ({ tags, match, limit, cursor }: {
tags: string
match: 'any' | 'all'
limit: number
cursor?: string }): Promise<{ posts: Post[]; nextCursor: string }> => {
export const fetchPosts = async (
{ tags, match, page, limit, cursor }: {
tags: string
match: 'any' | 'all'
page?: number
limit?: number
cursor?: string }
): Promise<{
posts: Post[]
count: number
nextCursor: string }> => {
const res = await axios.get (`${ API_BASE_URL }/posts`, {
params: { tags, match, limit, ...(cursor && { cursor }) } })
params: {
tags,
match,
...(page && { page }),
...(limit && { limit }),
...(cursor && { cursor }) } })
return toCamel (res.data as any, { deep: true }) as { posts: Post[]
nextCursor: string }
return toCamel (res.data as any, { deep: true }) as {
posts: Post[]
count: number
nextCursor: string }
}
+101
View File
@@ -0,0 +1,101 @@
import type { Content, Parent, Root, RootContent } from 'mdast'
const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')
export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => {
const names = [...pageNames].sort ((a, b) => b.length - a.length)
if (names.length === 0)
{
return () => {
;
}
}
const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g')
return (tree: Root) => {
const edits: { parent: Parent; index: number; parts: RootContent[] }[] = []
const walk = (node: Content | Root, ancestors: Parent[]) => {
if (!(node) || (typeof node !== 'object'))
return
if (!(ancestors.some (ancestor => ['link',
'linkReference',
'image',
'imageReference',
'code',
'inlineCode'].includes (ancestor?.type)))
&& (node.type === 'text'))
{
const value = node.value ?? ''
if (value)
{
re.lastIndex = 0
let m: RegExpExecArray | null
let last = 0
const parts: RootContent[] = []
while (m = re.exec (value))
{
const start = m.index
const end = start + m[0].length
if (start > last)
parts.push ({ type: 'text', value: value.slice (last, start) })
const name = m[1]
parts.push ({ type: 'link',
url: `${ basePath }/${ encodeURIComponent (name) }`,
title: null,
children: [{ type: 'text', value: name }],
data: { hProperties: { 'data-wiki': '1' } } })
last = end
}
if (parts.length)
{
if (last < value.length)
parts.push ({ type: 'text', value: value.slice (last) })
const parent = ancestors[ancestors.length - 1]
if (parent && Array.isArray (parent.children))
{
const index = parent.children.indexOf (node)
if (index >= 0)
edits.push ({ parent, index, parts })
}
}
}
}
const maybeChidren = (node as any).children
if (Array.isArray (maybeChidren))
{
const parent = node as Parent
for (let i = 0; i < maybeChidren.length; ++i)
{
const child: Content | undefined = maybeChidren[i]
if (!(child))
continue
walk (child, ancestors.concat (parent))
}
}
}
walk (tree, [])
for (let i = edits.length - 1; i >= 0; --i)
{
const { parent, index, parts } = edits[i]
if (!(parent) || !(Array.isArray (parent.children)))
continue
if (0 <= index && index < parent.children.length)
parent.children.splice (index, 1, ...parts)
}
}
}
@@ -0,0 +1,104 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { Link, useLocation } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config'
import type { FC } from 'react'
import type { PostTagChange } from '@/types'
export default (() => {
const [changes, setChanges] = useState<PostTagChange[]> ([])
const [totalPages, setTotalPages] = useState<number> (0)
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
// 投稿列の結合で使用
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])
return (
<MainArea>
<Helmet>
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>
{id && <>: 稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>}
</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>
<Pagination page={page} totalPages={totalPages}/>
</MainArea>)
}) satisfies FC
+23 -11
View File
@@ -7,6 +7,7 @@ import { Link, useLocation, useNavigationType } from 'react-router-dom'
import PostList from '@/components/PostList'
import TagSidebar from '@/components/TagSidebar'
import WikiBody from '@/components/WikiBody'
import Pagination from '@/components/common/Pagination'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config'
@@ -24,6 +25,7 @@ export default () => {
const [cursor, setCursor] = useState ('')
const [loading, setLoading] = useState (false)
const [posts, setPosts] = useState<Post[]> ([])
const [totalPages, setTotalPages] = useState (0)
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const loadMore = async (withCursor: boolean) => {
@@ -32,13 +34,15 @@ export default () => {
const data = await fetchPosts ({
tags: tags.join (' '),
match: anyFlg ? 'any' : 'all',
limit: 20,
...(page && { page }),
...(limit && { limit }),
...(withCursor && { cursor }) })
setPosts (posts => (
[...((new Map ([...(withCursor ? posts : []), ...data.posts]
.map (post => [post.id, post])))
.values ())]))
setCursor (data.nextCursor)
setTotalPages (Math.ceil (data.count / limit))
setLoading (false)
}
@@ -48,6 +52,8 @@ export default () => {
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
const tags = tagsQuery.split (' ').filter (e => e !== '')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
useEffect(() => {
const observer = new IntersectionObserver (entries => {
@@ -64,7 +70,8 @@ export default () => {
}, [loaderRef, loading])
useLayoutEffect (() => {
const savedState = sessionStorage.getItem (`posts:${ tagsQuery }`)
// TODO: 無限ロード用
const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null
if (savedState && navigationType === 'POP')
{
const { posts, cursor, scroll } = JSON.parse (savedState)
@@ -121,18 +128,23 @@ export default () => {
<MainArea>
<TabGroup>
<Tab name="広場">
{posts.length
{posts.length > 0
? (
<PostList posts={posts} onClick={() => {
const statesToSave = {
posts, cursor,
scroll: containerRef.current?.scrollTop ?? 0 }
sessionStorage.setItem (`posts:${ tagsQuery }`,
JSON.stringify (statesToSave))
}}/>)
<>
<PostList posts={posts} onClick={() => {
// TODO: 無限ロード用なので復活時に戻す.
// const statesToSave = {
// posts, cursor,
// scroll: containerRef.current?.scrollTop ?? 0 }
// sessionStorage.setItem (`posts:${ tagsQuery }`,
// JSON.stringify (statesToSave))
}}/>
<Pagination page={page} totalPages={totalPages}/>
</>)
: !(loading) && '広場には何もありませんよ.'}
{loading && 'Loading...'}
<div ref={loaderRef} className="h-12"/>
{/* TODO: 無限ローディング復活までコメント・アウト */}
{/* <div ref={loaderRef} className="h-12"/> */}
</Tab>
{tags.length === 1 && (
<Tab name="Wiki">
+14 -3
View File
@@ -36,21 +36,32 @@ export default () => {
if (/^\d+$/.test (title))
{
void (async () => {
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
const data = res.data as WikiPage
navigate (`/wiki/${ data.title }`, { replace: true })
setWikiPage (undefined)
try
{
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
const data = res.data as WikiPage
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
}
catch
{
;
}
}) ()
return
}
void (async () => {
setWikiPage (undefined)
try
{
const res = await axios.get (
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
{ params: version ? { version } : { } })
const data = toCamel (res.data as any, { deep: true }) as WikiPage
if (data.title !== title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
setWikiPage (data)
WikiIdBus.set (data.id)
}
+4 -4
View File
@@ -40,10 +40,10 @@ export default () => {
{diff
? (
diff.diff.map (d => (
<span className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800',
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
{d.content == '\n' ? <br/> : d.content}
</span>)))
<p className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800',
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
{d.content}
</p>)))
: 'Loading...'}
</div>
</MainArea>)
+33 -25
View File
@@ -12,6 +12,8 @@ import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css'
import type { FC } from 'react'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
@@ -19,7 +21,7 @@ const mdParser = new MarkdownIt
type Props = { user: User | null }
export default ({ user }: Props) => {
export default (({ user }: Props) => {
if (!(['admin', 'member'].some (r => user?.role === r)))
return <Forbidden/>
@@ -27,8 +29,9 @@ export default ({ user }: Props) => {
const navigate = useNavigate ()
const [title, setTitle] = useState ('')
const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')
const handleSubmit = async () => {
const formData = new FormData ()
@@ -51,10 +54,12 @@ export default ({ user }: Props) => {
useEffect (() => {
void (async () => {
setLoading (true)
const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`)
const data = res.data as WikiPage
setTitle (data.title)
setBody (data.body)
setLoading (false)
}) ()
}, [id])
@@ -66,30 +71,33 @@ export default ({ user }: Props) => {
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">Wiki </h1>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{loading ? 'Loading...' : (
<>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</button>
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</button>
</>)}
</div>
</MainArea>)
}
}) satisfies FC<Props>
+5 -15
View File
@@ -41,30 +41,20 @@ export default () => {
</thead>
<tbody>
{changes.map (change => (
<tr key={change.sha}>
<tr key={change.revisionId}>
<td>
{change.changeType === 'update' && (
<Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.sha }`}>
{change.pred != null && (
<Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
</Link>)}
</td>
<td className="p-2">
<Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.sha }`}>
<Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
{change.wikiPage.title}
</Link>
</td>
<td className="p-2">
{(() => {
switch (change.changeType)
{
case 'create':
return '新規'
case 'update':
return '更新'
case 'delete':
return '削除'
}
}) ()}
{change.pred == null ? '新規' : '更新'}
</td>
<td className="p-2">
<Link to={`/users/${ change.user.id }`}>
+44 -31
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts'
import type { ReactNode } from 'react'
export type Category = typeof CATEGORIES[number]
export type Menu = MenuItem[]
@@ -29,19 +29,28 @@ export type Post = {
originalCreatedFrom: string | null
originalCreatedBefore: string | null }
export type SubMenuItem = {
component: React.ReactNode
visible: boolean
} | {
name: string
to: string
visible?: boolean }
export type PostTagChange = {
post: Post
tag: Tag
user?: User
changeType: 'add' | 'remove'
timestamp: string }
export type SubMenuItem =
| { component: ReactNode
visible: boolean }
| { name: string
to: string
visible?: boolean }
export type Tag = {
id: number
name: string
category: Category
postCount: number }
id: number
name: string
category: Category
postCount: number
hasWiki: boolean
children?: Tag[]
matchedAlias?: string | null }
export type User = {
id: number
@@ -52,29 +61,33 @@ export type User = {
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
export type WikiPage = {
id: number
title: string
body: string
sha: string
pred?: string
succ?: string
updatedAt?: string }
id: number
title: string
createdUserId: number
updatedUserId: number
createdAt: string
updatedAt: string
body: string
revisionId: number
pred: number | null
succ: number | null }
export type WikiPageChange = {
sha: string
pred?: string
succ?: string
wikiPage: WikiPage
user: User
changeType: string
revisionId: number
pred: number | null
succ: null
wikiPage: Pick<WikiPage, 'id' | 'title'>
user: Pick<User, 'id' | 'name'>
kind: 'content' | 'redirect'
message: string | null
timestamp: string }
export type WikiPageDiff = {
wikiPageId: number
title: string
olderSha: string
newerSha: string
diff: WikiPageDiffDiff[] }
wikiPageId: number
title: string
olderRevisionId: number | null
newerRevisionId: number
diff: WikiPageDiffDiff[] }
export type WikiPageDiffDiff = {
type: 'context' | 'added' | 'removed'