Browse Source

#103 タグ補完

#103
みてるぞ 2 weeks ago
parent
commit
3119d475e5
7 changed files with 108 additions and 34 deletions
  1. +5
    -10
      frontend/src/components/PostEditForm.tsx
  2. +84
    -0
      frontend/src/components/PostFormTagsArea.tsx
  3. +2
    -2
      frontend/src/components/TagSearch.tsx
  4. +2
    -3
      frontend/src/components/TagSearchBox.tsx
  5. +6
    -7
      frontend/src/components/common/TextArea.tsx
  6. +1
    -1
      frontend/src/pages/posts/PostListPage.tsx
  7. +8
    -11
      frontend/src/pages/posts/PostNewPage.tsx

+ 5
- 10
frontend/src/components/PostEditForm.tsx View File

@@ -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 className="flex-1 block font-semibold">タイトル</label>
</div>
<Label>タイトル</Label>
<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>
<label className="block font-semibold">タグ</label>
<TextArea value={tags}
onChange={ev => setTags (ev.target.value)}/>
</div>
<PostFormTagsArea tags={tags} setTags={setTags}/>


{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}


+ 84
- 0
frontend/src/components/PostFormTagsArea.tsx View File

@@ -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>

+ 2
- 2
frontend/src/components/TagSearch.tsx View File

@@ -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)
} }




+ 2
- 3
frontend/src/components/TagSearchBox.tsx View File

@@ -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>))}


+ 6
- 7
frontend/src/components/common/TextArea.tsx View File

@@ -1,10 +1,9 @@
import React from 'react'
import { forwardRef } from 'react'


type Props = { value?: string
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void }
import type { TextareaHTMLAttributes } from 'react'


type Props = TextareaHTMLAttributes<HTMLTextAreaElement>


export default ({ value, onChange }: Props) => (
<textarea className="rounded border w-full p-2 h-32"
value={value}
onChange={onChange}/>)

export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => (
<textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>))

+ 1
- 1
frontend/src/pages/posts/PostListPage.tsx View File

@@ -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">


+ 8
- 11
frontend/src/pages/posts/PostNewPage.tsx View File

@@ -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/>


@@ -131,7 +133,7 @@ export default ({ user }: Props) => {
{/* タイトル */} {/* タイトル */}
<div> <div>
<Label checkBox={{ <Label checkBox={{
label: '自動',
label: '自動',
checked: titleAutoFlg, checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}> onChange: ev => setTitleAutoFlg (ev.target.checked)}}>
タイトル タイトル
@@ -147,7 +149,7 @@ export default ({ user }: Props) => {
{/* サムネール */} {/* サムネール */}
<div> <div>
<Label checkBox={{ <Label checkBox={{
label: '自動',
label: '自動',
checked: thumbnailAutoFlg, checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}> onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}>
サムネール サムネール
@@ -177,12 +179,7 @@ export default ({ user }: Props) => {
</div> </div>


{/* タグ */} {/* タグ */}
{/* TextArea で自由形式にする */}
<div>
<Label>タグ</Label>
<TextArea value={tags}
onChange={ev => setTags (ev.target.value)}/>
</div>
<PostFormTagsArea tags={tags} setTags={setTags}/>


{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
@@ -192,4 +189,4 @@ export default ({ user }: Props) => {
</Button> </Button>
</Form> </Form>
</MainArea>) </MainArea>)
}
}) satisfies FC<Props>

Loading…
Cancel
Save