#103 タグ補完
This commit is contained in:
@@ -2,7 +2,8 @@ import axios from 'axios'
|
|||||||
import toCamel from 'camelcase-keys'
|
import toCamel from 'camelcase-keys'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import TextArea from '@/components/common/TextArea'
|
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||||
|
import Label from '@/components/common/Label'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export default (({ post, onSave }: Props) => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags },
|
const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags },
|
||||||
{ headers: { 'Content-Type': 'multipart/form-data',
|
{ headers: { 'Content-Type': 'multipart/form-data',
|
||||||
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } )
|
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } } )
|
||||||
const data = toCamel (res.data as any, { deep: true }) as Post
|
const data = toCamel (res.data as any, { deep: true }) as Post
|
||||||
onSave ({ ...post,
|
onSave ({ ...post,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -35,9 +36,7 @@ export default (({ post, onSave }: Props) => {
|
|||||||
<div className="max-w-xl pt-2 space-y-4">
|
<div className="max-w-xl pt-2 space-y-4">
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-2 mb-1">
|
<Label>タイトル</Label>
|
||||||
<label className="flex-1 block font-semibold">タイトル</label>
|
|
||||||
</div>
|
|
||||||
<input type="text"
|
<input type="text"
|
||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -45,11 +44,7 @@ export default (({ post, onSave }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* タグ */}
|
{/* タグ */}
|
||||||
<div>
|
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||||
<label className="block font-semibold">タグ</label>
|
|
||||||
<TextArea value={tags}
|
|
||||||
onChange={ev => setTags (ev.target.value)}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button onClick={handleSubmit}
|
<Button onClick={handleSubmit}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import toCamel from 'camelcase-keys'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
|
import Label from '@/components/common/Label'
|
||||||
|
import TextArea from '@/components/common/TextArea'
|
||||||
|
import { API_BASE_URL } from '@/config'
|
||||||
|
|
||||||
|
import type { FC, SyntheticEvent } from 'react'
|
||||||
|
|
||||||
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
const SEP = /\s/
|
||||||
|
|
||||||
|
|
||||||
|
const getTokenAt = (value: string, pos: number) => {
|
||||||
|
let start = pos
|
||||||
|
while (start > 0 && !(SEP.test (value[start - 1])))
|
||||||
|
--start
|
||||||
|
|
||||||
|
let end = pos
|
||||||
|
while (end < value.length && !(SEP.test (value[end])))
|
||||||
|
++end
|
||||||
|
|
||||||
|
return { start, end, token: value.slice (start, end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const replaceToken = (value: string, start: number, end: number, text: string) => (
|
||||||
|
`${ value.slice (0, start) }${ text }${ value.slice (end) }`)
|
||||||
|
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tags: string
|
||||||
|
setTags: (tags: string) => void }
|
||||||
|
|
||||||
|
|
||||||
|
export default (({ tags, setTags }: Props) => {
|
||||||
|
const ref = useRef<HTMLTextAreaElement> (null)
|
||||||
|
|
||||||
|
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||||
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
|
const handleTagSelect = (tag: Tag) => {
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
const textarea = ref.current!
|
||||||
|
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name)
|
||||||
|
setTags (newValue)
|
||||||
|
requestAnimationFrame (async () => {
|
||||||
|
const p = bounds.start + tag.name.length
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = p
|
||||||
|
textarea.focus ()
|
||||||
|
await recompute (p, newValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const recompute = async (pos: number, v: string = tags) => {
|
||||||
|
const { start, end, token } = getTokenAt (v, pos)
|
||||||
|
setBounds ({ start, end })
|
||||||
|
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } })
|
||||||
|
setSuggestions (toCamel (res.data as any, { deep: true }) as Tag[])
|
||||||
|
setSuggestionsVsbl (suggestions.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label>タグ</Label>
|
||||||
|
<TextArea
|
||||||
|
ref={ref}
|
||||||
|
value={tags}
|
||||||
|
onChange={ev => setTags (ev.target.value)}
|
||||||
|
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
|
||||||
|
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
||||||
|
await recompute (pos)
|
||||||
|
}}/>
|
||||||
|
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length
|
||||||
|
? suggestions
|
||||||
|
: [] as Tag[]}
|
||||||
|
activeIndex={-1}
|
||||||
|
onSelect={handleTagSelect}/>
|
||||||
|
</div>)
|
||||||
|
}) satisfies FC<Props>
|
||||||
@@ -15,9 +15,9 @@ export default (() => {
|
|||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -33,7 +33,7 @@ export default (() => {
|
|||||||
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
|
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
|
||||||
const data = res.data as Tag[]
|
const data = res.data as Tag[]
|
||||||
setSuggestions (data)
|
setSuggestions (data)
|
||||||
if (suggestions.length)
|
if (suggestions.length > 0)
|
||||||
setSuggestionsVsbl (true)
|
setSuggestionsVsbl (true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Props = { suggestions: Tag[]
|
|||||||
|
|
||||||
|
|
||||||
export default (({ suggestions, activeIndex, onSelect }: Props) => {
|
export default (({ suggestions, activeIndex, onSelect }: Props) => {
|
||||||
if (!(suggestions.length))
|
if (suggestions.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,8 +21,7 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => {
|
|||||||
<li key={tag.id}
|
<li key={tag.id}
|
||||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
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')}
|
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||||
onMouseDown={() => onSelect (tag)}
|
onMouseDown={() => onSelect (tag)}>
|
||||||
>
|
|
||||||
{tag.name}
|
{tag.name}
|
||||||
{<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>}
|
{<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>}
|
||||||
</li>))}
|
</li>))}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react'
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
type Props = { value?: string
|
import type { TextareaHTMLAttributes } from 'react'
|
||||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void }
|
|
||||||
|
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
|
|
||||||
export default ({ value, onChange }: Props) => (
|
export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => (
|
||||||
<textarea className="rounded border w-full p-2 h-32"
|
<textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>))
|
||||||
value={value}
|
|
||||||
onChange={onChange}/>)
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default () => {
|
|||||||
}}/>)
|
}}/>)
|
||||||
: !(loading) && '広場には何もありませんよ.'}
|
: !(loading) && '広場には何もありませんよ.'}
|
||||||
{loading && 'Loading...'}
|
{loading && 'Loading...'}
|
||||||
<div ref={loaderRef} className="h-12"></div>
|
<div ref={loaderRef} className="h-12"/>
|
||||||
</Tab>
|
</Tab>
|
||||||
{tags.length === 1 && (
|
{tags.length === 1 && (
|
||||||
<Tab name="Wiki">
|
<Tab name="Wiki">
|
||||||
|
|||||||
@@ -3,22 +3,24 @@ import { useEffect, useState, useRef } from 'react'
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||||
import Form from '@/components/common/Form'
|
import Form from '@/components/common/Form'
|
||||||
import Label from '@/components/common/Label'
|
import Label from '@/components/common/Label'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import TextArea from '@/components/common/TextArea'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
|
||||||
export default ({ user }: Props) => {
|
export default (({ user }: Props) => {
|
||||||
if (!(['admin', 'member'].some (r => user?.role === r)))
|
if (!(['admin', 'member'].some (r => user?.role === r)))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
@@ -177,12 +179,7 @@ export default ({ user }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* タグ */}
|
{/* タグ */}
|
||||||
{/* TextArea で自由形式にする */}
|
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||||
<div>
|
|
||||||
<Label>タグ</Label>
|
|
||||||
<TextArea value={tags}
|
|
||||||
onChange={ev => setTags (ev.target.value)}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button onClick={handleSubmit}
|
<Button onClick={handleSubmit}
|
||||||
@@ -192,4 +189,4 @@ export default ({ user }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}) satisfies FC<Props>
|
||||||
|
|||||||
Reference in New Issue
Block a user