|
@@ -1,23 +1,111 @@ |
|
|
import axios from 'axios' |
|
|
import axios from 'axios' |
|
|
import toCamel from 'camelcase-keys' |
|
|
import toCamel from 'camelcase-keys' |
|
|
import { useEffect, useState } from 'react' |
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from 'react' |
|
|
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa' |
|
|
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa' |
|
|
import { useParams } from 'react-router-dom' |
|
|
|
|
|
|
|
|
import { useParams, useSearchParams } from 'react-router-dom' |
|
|
|
|
|
|
|
|
import ThreadCanvas from '@/components/threads/ThreadCanvas' |
|
|
import ThreadCanvas from '@/components/threads/ThreadCanvas' |
|
|
import { API_BASE_URL } from '@/config' |
|
|
import { API_BASE_URL } from '@/config' |
|
|
|
|
|
|
|
|
|
|
|
import type { ThreadCanvasHandle } from '@/components/threads/ThreadCanvas' |
|
|
import type { Post, Thread } from '@/types' |
|
|
import type { Post, Thread } from '@/types' |
|
|
|
|
|
|
|
|
|
|
|
const Sort = { |
|
|
|
|
|
Newest: 'newest', |
|
|
|
|
|
Oldest: 'oldest', |
|
|
|
|
|
Likes: 'likes', |
|
|
|
|
|
Dislikes: 'dislikes' } as const |
|
|
|
|
|
type Sort = (typeof Sort)[keyof typeof Sort] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const reformPost = (post: Post): Post => ({ |
|
|
|
|
|
...post, |
|
|
|
|
|
createdAt: (new Date (post.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') }) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default () => { |
|
|
export default () => { |
|
|
const { id } = useParams () |
|
|
const { id } = useParams () |
|
|
|
|
|
|
|
|
|
|
|
const canvasRef = useRef<ThreadCanvasHandle> (null) |
|
|
|
|
|
|
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams () |
|
|
|
|
|
const sort = searchParams.get ('sort') ?? 'created_at' |
|
|
|
|
|
const order = searchParams.get ('order') ?? 'desc' |
|
|
|
|
|
const sortKey = (() => { |
|
|
|
|
|
if (sort === 'score') |
|
|
|
|
|
{ |
|
|
|
|
|
if (order === 'asc') |
|
|
|
|
|
return Sort.Dislikes |
|
|
|
|
|
return Sort.Likes |
|
|
|
|
|
} |
|
|
|
|
|
else |
|
|
|
|
|
{ |
|
|
|
|
|
if (order === 'asc') |
|
|
|
|
|
return Sort.Oldest |
|
|
|
|
|
return Sort.Newest |
|
|
|
|
|
} |
|
|
|
|
|
}) () |
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState (true) |
|
|
const [loading, setLoading] = useState (true) |
|
|
|
|
|
const [message, setMessage] = useState ('') |
|
|
|
|
|
const [name, setName] = useState ('') |
|
|
|
|
|
const [password, setPassword] = useState ('') |
|
|
const [posts, setPosts] = useState<Post[]> ([]) |
|
|
const [posts, setPosts] = useState<Post[]> ([]) |
|
|
|
|
|
const [sending, setSending] = useState (false) |
|
|
const [thread, setThread] = useState<Thread | null> (null) |
|
|
const [thread, setThread] = useState<Thread | null> (null) |
|
|
|
|
|
const [withImage, setWithImage] = useState (false) |
|
|
|
|
|
|
|
|
|
|
|
const handleSend = async () => { |
|
|
|
|
|
if (!(message) && !(withImage)) |
|
|
|
|
|
{ |
|
|
|
|
|
alert ('メッセージ書くか画像描くかどっちかはしろ.') |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setSending (true) |
|
|
|
|
|
try |
|
|
|
|
|
{ |
|
|
|
|
|
const form = new FormData |
|
|
|
|
|
if (withImage) |
|
|
|
|
|
{ |
|
|
|
|
|
if (!(canvasRef.current)) |
|
|
|
|
|
return |
|
|
|
|
|
const { dataURL } = await canvasRef.current.exportAll () |
|
|
|
|
|
const blob = await (await fetch (dataURL)).blob () |
|
|
|
|
|
form.append ('image', new File ([blob], `kekec_${ Date.now () }.png`, { type: 'image/png' })) |
|
|
|
|
|
} |
|
|
|
|
|
form.append ('name', name) |
|
|
|
|
|
form.append ('message', message) |
|
|
|
|
|
form.append ('password', password) |
|
|
|
|
|
const res = await axios.post (`${ API_BASE_URL }/threads/${ id }/posts`, |
|
|
|
|
|
form, |
|
|
|
|
|
{ headers: { 'Content-Type': 'multipart/form-data' } }) |
|
|
|
|
|
const data: Post = toCamel (res.data as any, { deep: true }) |
|
|
|
|
|
setPosts(prev => [reformPost (data), ...prev]) |
|
|
|
|
|
|
|
|
|
|
|
setMessage ('') |
|
|
|
|
|
if (withImage) |
|
|
|
|
|
{ |
|
|
|
|
|
canvasRef.current!.clear () |
|
|
|
|
|
setWithImage (false) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
localStorage.setItem ('name', name) |
|
|
|
|
|
localStorage.setItem ('password', password) |
|
|
|
|
|
} |
|
|
|
|
|
finally |
|
|
|
|
|
{ |
|
|
|
|
|
setSending (false) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
useEffect (() => { |
|
|
useEffect (() => { |
|
|
|
|
|
const nameRaw = localStorage.getItem ('name') |
|
|
|
|
|
nameRaw && setName (nameRaw) |
|
|
|
|
|
|
|
|
|
|
|
const passRaw = localStorage.getItem ('password') |
|
|
|
|
|
passRaw && setName (passRaw) |
|
|
|
|
|
|
|
|
const fetchThread = async () => { |
|
|
const fetchThread = async () => { |
|
|
try |
|
|
try |
|
|
{ |
|
|
{ |
|
@@ -29,16 +117,17 @@ export default () => { |
|
|
setThread (null) |
|
|
setThread (null) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
fetchThread () |
|
|
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
useEffect (() => { |
|
|
const fetchPosts = async () => { |
|
|
const fetchPosts = async () => { |
|
|
setLoading (true) |
|
|
|
|
|
try |
|
|
try |
|
|
{ |
|
|
{ |
|
|
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`) |
|
|
|
|
|
|
|
|
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`, |
|
|
|
|
|
{ params: { sort, order } }) |
|
|
const data = toCamel (res.data as any, { deep: true }) as Post[] |
|
|
const data = toCamel (res.data as any, { deep: true }) as Post[] |
|
|
setPosts (data.map (p => ({ |
|
|
|
|
|
...p, |
|
|
|
|
|
createdAt: (new Date (p.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') }))) |
|
|
|
|
|
|
|
|
setPosts (data.map (reformPost)) |
|
|
} |
|
|
} |
|
|
catch |
|
|
catch |
|
|
{ |
|
|
{ |
|
@@ -46,10 +135,8 @@ export default () => { |
|
|
} |
|
|
} |
|
|
setLoading (false) |
|
|
setLoading (false) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
fetchThread () |
|
|
|
|
|
fetchPosts () |
|
|
fetchPosts () |
|
|
}, []) |
|
|
|
|
|
|
|
|
}, [sortKey]) |
|
|
|
|
|
|
|
|
useEffect (() => { |
|
|
useEffect (() => { |
|
|
if (!(thread)) |
|
|
if (!(thread)) |
|
@@ -59,70 +146,148 @@ export default () => { |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<> |
|
|
<> |
|
|
<ThreadCanvas /> |
|
|
|
|
|
|
|
|
<div className="mb-16"> |
|
|
|
|
|
<div> |
|
|
|
|
|
<label>名前:</label> |
|
|
|
|
|
<input type="text" |
|
|
|
|
|
value={name} |
|
|
|
|
|
onChange={ev => setName (ev.target.value)} /> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div> |
|
|
|
|
|
<label>削除用パスワード:</label> |
|
|
|
|
|
<input type="password" |
|
|
|
|
|
value={password} |
|
|
|
|
|
onChange={ev => setPassword (ev.target.value)} /> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div> |
|
|
|
|
|
<label>メッセージ:</label> |
|
|
|
|
|
<textarea value={message} |
|
|
|
|
|
onChange={ev => setMessage (ev.target.value)} /> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div> |
|
|
|
|
|
<label> |
|
|
|
|
|
<input type="checkbox" |
|
|
|
|
|
checked={withImage} |
|
|
|
|
|
onChange={ev => setWithImage (ev.target.checked)} /> |
|
|
|
|
|
イラストを投稿に含める |
|
|
|
|
|
</label> |
|
|
|
|
|
</div> |
|
|
|
|
|
<ThreadCanvas ref={canvasRef} visible={withImage} /> |
|
|
|
|
|
<div> |
|
|
|
|
|
<button onClick={handleSend} disabled={sending}> |
|
|
|
|
|
{sending ? '送信中...' : '送信'} |
|
|
|
|
|
</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
{loading ? 'Loading...' : ( |
|
|
{loading ? 'Loading...' : ( |
|
|
posts.length > 0 |
|
|
posts.length > 0 |
|
|
? posts.map (post => ( |
|
|
|
|
|
<div key={post.id} |
|
|
|
|
|
className="bg-white dark:bg-gray-800 p-3 m-4 |
|
|
|
|
|
border border-gray-400 rounded-xl |
|
|
|
|
|
text-center"> |
|
|
|
|
|
<div className="flex justify-between items-center px-2 py-1"> |
|
|
|
|
|
<span>{post.postNo}: {post.name || '名なしさん'}</span> |
|
|
|
|
|
<span>{post.createdAt}</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="flex items-center px-4 pt-1 pb-3"> |
|
|
|
|
|
<a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap" |
|
|
|
|
|
href="#" |
|
|
|
|
|
onClick={async ev => { |
|
|
|
|
|
ev.preventDefault () |
|
|
|
|
|
try |
|
|
|
|
|
{ |
|
|
|
|
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`) |
|
|
|
|
|
setPosts (prev => prev.map (p => (p.id === post.id |
|
|
|
|
|
? { ...p, bad: p.bad + 1 } |
|
|
|
|
|
: p))) |
|
|
|
|
|
} |
|
|
|
|
|
catch |
|
|
|
|
|
{ |
|
|
|
|
|
; |
|
|
|
|
|
} |
|
|
|
|
|
}}> |
|
|
|
|
|
<FaThumbsDown className="inline" /> {post.bad} |
|
|
|
|
|
</a> |
|
|
|
|
|
<div className="h-2 bg-blue-600 dark:bg-blue-300" |
|
|
|
|
|
style={{ width: `${ post.good + post.bad === 0 |
|
|
|
|
|
? 50 |
|
|
|
|
|
: post.bad * 100 / (post.good + post.bad) }%` }}> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="h-2 bg-red-600 dark:bg-red-300" |
|
|
|
|
|
style={{ width: `${ post.good + post.bad === 0 |
|
|
|
|
|
? 50 |
|
|
|
|
|
: post.good * 100 / (post.good + post.bad) }%` }}> |
|
|
|
|
|
</div> |
|
|
|
|
|
<a className="text-red-600 dark:text-red-300 ml-1 whitespace-nowrap" |
|
|
|
|
|
href="#" |
|
|
|
|
|
onClick={async ev => { |
|
|
|
|
|
ev.preventDefault () |
|
|
|
|
|
try |
|
|
|
|
|
{ |
|
|
|
|
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`) |
|
|
|
|
|
setPosts (prev => prev.map (p => (p.id === post.id |
|
|
|
|
|
? { ...p, good: p.good + 1 } |
|
|
|
|
|
: p))) |
|
|
|
|
|
} |
|
|
|
|
|
catch |
|
|
|
|
|
{ |
|
|
|
|
|
; |
|
|
|
|
|
} |
|
|
|
|
|
}}> |
|
|
|
|
|
{post.good} <FaThumbsUp className="inline" /> |
|
|
|
|
|
</a> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="bg-white inline-block border border-black dark:border-white"> |
|
|
|
|
|
<img src={`${ API_BASE_URL }${ post.imageUrl }`} /> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div>)) |
|
|
|
|
|
|
|
|
? ( |
|
|
|
|
|
<> |
|
|
|
|
|
<div> |
|
|
|
|
|
<label> |
|
|
|
|
|
並べ替え: |
|
|
|
|
|
<select value={sortKey} onChange={ev => { |
|
|
|
|
|
switch (ev.target.value) |
|
|
|
|
|
{ |
|
|
|
|
|
case Sort.Newest: |
|
|
|
|
|
searchParams.set ('sort', 'created_at') |
|
|
|
|
|
searchParams.set ('order', 'desc') |
|
|
|
|
|
break |
|
|
|
|
|
case Sort.Likes: |
|
|
|
|
|
searchParams.set ('sort', 'score') |
|
|
|
|
|
searchParams.set ('order', 'desc') |
|
|
|
|
|
break |
|
|
|
|
|
case Sort.Dislikes: |
|
|
|
|
|
searchParams.set ('sort', 'score') |
|
|
|
|
|
searchParams.set ('order', 'asc') |
|
|
|
|
|
break |
|
|
|
|
|
case Sort.Oldest: |
|
|
|
|
|
searchParams.set ('sort', 'created_at') |
|
|
|
|
|
searchParams.set ('order', 'asc') |
|
|
|
|
|
break |
|
|
|
|
|
} |
|
|
|
|
|
setSearchParams (searchParams) |
|
|
|
|
|
}}> |
|
|
|
|
|
<option value={Sort.Newest}>投稿が新しい順</option> |
|
|
|
|
|
<option value={Sort.Likes}>評価が高い順</option> |
|
|
|
|
|
<option value={Sort.Dislikes}>評価が低い順</option> |
|
|
|
|
|
<option value={Sort.Oldest}>投稿が古い順</option> |
|
|
|
|
|
</select> |
|
|
|
|
|
</label> |
|
|
|
|
|
</div> |
|
|
|
|
|
{posts.map (post => ( |
|
|
|
|
|
<div key={post.id} |
|
|
|
|
|
id={post.postNo.toString ()} |
|
|
|
|
|
className="bg-white dark:bg-gray-800 p-3 m-4 |
|
|
|
|
|
border border-gray-400 rounded-xl |
|
|
|
|
|
text-center"> |
|
|
|
|
|
<div className="flex justify-between items-center px-2 py-1"> |
|
|
|
|
|
<span>{post.postNo}: {post.name || '名なしさん'}</span> |
|
|
|
|
|
<span>{post.createdAt}</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="flex items-center px-4 pt-1 pb-3"> |
|
|
|
|
|
<a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap" |
|
|
|
|
|
href="#" |
|
|
|
|
|
onClick={async ev => { |
|
|
|
|
|
ev.preventDefault () |
|
|
|
|
|
try |
|
|
|
|
|
{ |
|
|
|
|
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`) |
|
|
|
|
|
setPosts (prev => prev.map (p => (p.id === post.id |
|
|
|
|
|
? { ...p, bad: p.bad + 1 } |
|
|
|
|
|
: p))) |
|
|
|
|
|
} |
|
|
|
|
|
catch |
|
|
|
|
|
{ |
|
|
|
|
|
; |
|
|
|
|
|
} |
|
|
|
|
|
}}> |
|
|
|
|
|
<FaThumbsDown className="inline" /> {post.bad} |
|
|
|
|
|
</a> |
|
|
|
|
|
<div className="h-2 bg-blue-600 dark:bg-blue-300" |
|
|
|
|
|
style={{ width: `${ post.good + post.bad === 0 |
|
|
|
|
|
? 50 |
|
|
|
|
|
: post.bad * 100 / (post.good + post.bad) }%` }}> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div className="h-2 bg-red-600 dark:bg-red-300" |
|
|
|
|
|
style={{ width: `${ post.good + post.bad === 0 |
|
|
|
|
|
? 50 |
|
|
|
|
|
: post.good * 100 / (post.good + post.bad) }%` }}> |
|
|
|
|
|
</div> |
|
|
|
|
|
<a className="text-red-600 dark:text-red-300 ml-1 whitespace-nowrap" |
|
|
|
|
|
href="#" |
|
|
|
|
|
onClick={async ev => { |
|
|
|
|
|
ev.preventDefault () |
|
|
|
|
|
try |
|
|
|
|
|
{ |
|
|
|
|
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`) |
|
|
|
|
|
setPosts (prev => prev.map (p => (p.id === post.id |
|
|
|
|
|
? { ...p, good: p.good + 1 } |
|
|
|
|
|
: p))) |
|
|
|
|
|
} |
|
|
|
|
|
catch |
|
|
|
|
|
{ |
|
|
|
|
|
; |
|
|
|
|
|
} |
|
|
|
|
|
}}> |
|
|
|
|
|
{post.good} <FaThumbsUp className="inline" /> |
|
|
|
|
|
</a> |
|
|
|
|
|
</div> |
|
|
|
|
|
{post.deletedAt |
|
|
|
|
|
? <em className="font-bold">削除されました.</em> |
|
|
|
|
|
: ( |
|
|
|
|
|
<> |
|
|
|
|
|
{post.message && ( |
|
|
|
|
|
<div className="text-left px-4 my-2"> |
|
|
|
|
|
{post.message} |
|
|
|
|
|
</div>)} |
|
|
|
|
|
{post.imageUrl && ( |
|
|
|
|
|
<div className="bg-white inline-block border border-black dark:border-white"> |
|
|
|
|
|
<img src={`${ API_BASE_URL }${ post.imageUrl }`} /> |
|
|
|
|
|
</div>)} |
|
|
|
|
|
</>)} |
|
|
|
|
|
</div>))} |
|
|
|
|
|
</>) |
|
|
: 'レスないよ(笑).')} |
|
|
: 'レスないよ(笑).')} |
|
|
</>) |
|
|
</>) |
|
|
} |
|
|
} |