@@ -2,7 +2,8 @@ import axios from 'axios' | |||
import toCamel from 'camelcase-keys' | |||
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 { API_BASE_URL } from '@/config' | |||
@@ -24,7 +25,7 @@ export default (({ post, onSave }: Props) => { | |||
const handleSubmit = async () => { | |||
const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, | |||
{ 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 | |||
onSave ({ ...post, | |||
title: data.title, | |||
@@ -35,9 +36,7 @@ export default (({ post, onSave }: Props) => { | |||
<div className="max-w-xl pt-2 space-y-4"> | |||
{/* タイトル */} | |||
<div> | |||
<div className="flex gap-2 mb-1"> | |||
<label className="flex-1 block font-semibold">タイトル</label> | |||
</div> | |||
<Label>タイトル</Label> | |||
<input type="text" | |||
className="w-full border rounded p-2" | |||
value={title} | |||
@@ -45,11 +44,7 @@ export default (({ post, onSave }: Props) => { | |||
</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} | |||
@@ -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 navigate = useNavigate () | |||
const [activeIndex, setActiveIndex] = useState (-1) | |||
const [search, setSearch] = useState ('') | |||
const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
const [activeIndex, setActiveIndex] = useState (-1) | |||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
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 data = res.data as Tag[] | |||
setSuggestions (data) | |||
if (suggestions.length) | |||
if (suggestions.length > 0) | |||
setSuggestionsVsbl (true) | |||
} | |||
@@ -10,7 +10,7 @@ type Props = { suggestions: Tag[] | |||
export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||
if (!(suggestions.length)) | |||
if (suggestions.length === 0) | |||
return | |||
return ( | |||
@@ -21,8 +21,7 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||
<li key={tag.id} | |||
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)} | |||
> | |||
onMouseDown={() => onSelect (tag)}> | |||
{tag.name} | |||
{<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>} | |||
</li>))} | |||
@@ -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}/>)) |
@@ -127,7 +127,7 @@ export default () => { | |||
}}/>) | |||
: !(loading) && '広場には何もありませんよ.'} | |||
{loading && 'Loading...'} | |||
<div ref={loaderRef} className="h-12"></div> | |||
<div ref={loaderRef} className="h-12"/> | |||
</Tab> | |||
{tags.length === 1 && ( | |||
<Tab name="Wiki"> | |||
@@ -3,22 +3,24 @@ import { useEffect, useState, useRef } from 'react' | |||
import { Helmet } from 'react-helmet-async' | |||
import { useNavigate } from 'react-router-dom' | |||
import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
import Form from '@/components/common/Form' | |||
import Label from '@/components/common/Label' | |||
import PageTitle from '@/components/common/PageTitle' | |||
import TextArea from '@/components/common/TextArea' | |||
import MainArea from '@/components/layout/MainArea' | |||
import { Button } from '@/components/ui/button' | |||
import { toast } from '@/components/ui/use-toast' | |||
import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
import Forbidden from '@/pages/Forbidden' | |||
import type { FC } from 'react' | |||
import type { User } from '@/types' | |||
type Props = { user: User | null } | |||
export default ({ user }: Props) => { | |||
export default (({ user }: Props) => { | |||
if (!(['admin', 'member'].some (r => user?.role === r))) | |||
return <Forbidden/> | |||
@@ -131,7 +133,7 @@ export default ({ user }: Props) => { | |||
{/* タイトル */} | |||
<div> | |||
<Label checkBox={{ | |||
label: '自動', | |||
label: '自動', | |||
checked: titleAutoFlg, | |||
onChange: ev => setTitleAutoFlg (ev.target.checked)}}> | |||
タイトル | |||
@@ -147,7 +149,7 @@ export default ({ user }: Props) => { | |||
{/* サムネール */} | |||
<div> | |||
<Label checkBox={{ | |||
label: '自動', | |||
label: '自動', | |||
checked: thumbnailAutoFlg, | |||
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}> | |||
サムネール | |||
@@ -177,12 +179,7 @@ export default ({ user }: Props) => { | |||
</div> | |||
{/* タグ */} | |||
{/* TextArea で自由形式にする */} | |||
<div> | |||
<Label>タグ</Label> | |||
<TextArea value={tags} | |||
onChange={ev => setTags (ev.target.value)}/> | |||
</div> | |||
<PostFormTagsArea tags={tags} setTags={setTags}/> | |||
{/* 送信 */} | |||
<Button onClick={handleSubmit} | |||
@@ -192,4 +189,4 @@ export default ({ user }: Props) => { | |||
</Button> | |||
</Form> | |||
</MainArea>) | |||
} | |||
}) satisfies FC<Props> |