| @@ -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} | ||||
| @@ -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 | |||||
| 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 && '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/> | ||||
| @@ -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> | |||||