This commit is contained in:
@@ -24,7 +24,12 @@ class ThreadPostsController < ApplicationController
|
||||
def create
|
||||
post = @thread.posts.new(post_params)
|
||||
if post.save
|
||||
render json: post, status: :created
|
||||
render json: post.as_json.merge(image_url: (
|
||||
if post.image.attached?
|
||||
Rails.application.routes.url_helpers.rails_blob_url(post.image, only_path: true)
|
||||
else
|
||||
nil
|
||||
end)), status: :created
|
||||
else
|
||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -37,6 +42,8 @@ class ThreadPostsController < ApplicationController
|
||||
end
|
||||
|
||||
def post_params
|
||||
params.require(:post).permit(:name, :body, :password)
|
||||
params.permit(:name, :message, :password, :image).transform_values { |v|
|
||||
v.presence
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,16 @@ class Post < ApplicationRecord
|
||||
belongs_to :thread, class_name: 'Topic', foreign_key: :thread_id
|
||||
has_one_attached :image
|
||||
|
||||
before_create :set_post_no
|
||||
|
||||
has_secure_password validations: false
|
||||
|
||||
scope :active, -> { where deleted_at: nil }
|
||||
|
||||
private
|
||||
|
||||
def set_post_no
|
||||
max_no = Post.where(thread_id:).maximum(:post_no) || 0
|
||||
self.post_no = max_no + 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import cn from 'classnames'
|
||||
import Konva from 'konva'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
import { FaRedo, FaUndo } from 'react-icons/fa'
|
||||
import { Layer, Line, Rect, Stage, Image } from 'react-konva'
|
||||
|
||||
import type Konva from 'konva'
|
||||
import type { ChangeEventHandler } from 'react'
|
||||
|
||||
type ImageItem = { src: string }
|
||||
@@ -113,7 +118,16 @@ const isLayer = (obj: unknown): obj is Layer => (
|
||||
&& Array.isArray ((obj as Layer).future))
|
||||
|
||||
|
||||
export default () => {
|
||||
export type ThreadCanvasHandle = {
|
||||
exportAll: () => Promise<{ dataURL: string
|
||||
width: number
|
||||
height: number }>
|
||||
clear: () => void }
|
||||
|
||||
type Props = { visible: boolean }
|
||||
|
||||
|
||||
export default forwardRef<ThreadCanvasHandle, Props> (({ visible }, ref) => {
|
||||
const drawingRef = useRef (false)
|
||||
const fileInputRef = useRef<HTMLInputElement> (null)
|
||||
const layersRef = useRef<Record<string, Konva.Layer>> ({ })
|
||||
@@ -321,6 +335,69 @@ export default () => {
|
||||
setStageHeight (480)
|
||||
}
|
||||
|
||||
const exportAllLayers = async (): Promise<string> => {
|
||||
const container = document.createElement ('div')
|
||||
const stage = new Konva.Stage ({
|
||||
container,
|
||||
width: stageWidth,
|
||||
height: stageHeight })
|
||||
|
||||
const bgLayer = new Konva.Layer
|
||||
bgLayer.add (new Konva.Rect ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
fill: 'white',
|
||||
listening: false }))
|
||||
stage.add (bgLayer)
|
||||
|
||||
layers.forEach (layer => {
|
||||
const kLayer = new Konva.Layer
|
||||
|
||||
const baseImg = images[layer.id]
|
||||
if (baseImg)
|
||||
{
|
||||
kLayer.add (new Konva.Image ({
|
||||
image: baseImg,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scaleX: stageWidth / baseImg.width,
|
||||
scaleY: stageHeight / baseImg.height,
|
||||
listening: false }))
|
||||
}
|
||||
|
||||
layer.lines.forEach (line => {
|
||||
if (line.mode === Mode.Paint)
|
||||
{
|
||||
const img = images[line.imageSrc]
|
||||
img && kLayer.add (new Konva.Image ({
|
||||
image: img,
|
||||
x: 0,
|
||||
y: 0,
|
||||
listening: false }))
|
||||
}
|
||||
else
|
||||
{
|
||||
kLayer.add (new Konva.Line ({
|
||||
points: line.points,
|
||||
stroke: line.mode === Mode.Rubber ? 'black' : line.stroke,
|
||||
strokeWidth: line.strokeWidth,
|
||||
tension: .5,
|
||||
lineCap: 'round',
|
||||
globalCompositeOperation: line.mode === Mode.Rubber ? 'destination-out' : 'source-over',
|
||||
listening: false }))
|
||||
}
|
||||
})
|
||||
|
||||
stage.add (kLayer)
|
||||
})
|
||||
|
||||
const dataURL = stage.toDataURL ({ mimeType: 'image/png', pixelRatio: 1 })
|
||||
stage.destroy ()
|
||||
return dataURL
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
try
|
||||
{
|
||||
@@ -374,8 +451,14 @@ export default () => {
|
||||
})
|
||||
}, [layers])
|
||||
|
||||
useImperativeHandle (ref, () => ({
|
||||
exportAll: async () => ({ dataURL: await exportAllLayers (),
|
||||
width: stageWidth,
|
||||
height: stageHeight }),
|
||||
clear: clearCanvas }))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn (!(visible) && 'hidden')}>
|
||||
<div>
|
||||
<div>
|
||||
<button onClick={() => {
|
||||
@@ -544,7 +627,7 @@ export default () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center mb-16">
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<div className="border border-black dark:border-white">
|
||||
<Stage className="touch-none"
|
||||
ref={node => {
|
||||
@@ -609,4 +692,4 @@ export default () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,23 +1,111 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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 { API_BASE_URL } from '@/config'
|
||||
|
||||
import type { ThreadCanvasHandle } from '@/components/threads/ThreadCanvas'
|
||||
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 () => {
|
||||
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 [message, setMessage] = useState ('')
|
||||
const [name, setName] = useState ('')
|
||||
const [password, setPassword] = useState ('')
|
||||
const [posts, setPosts] = useState<Post[]> ([])
|
||||
const [sending, setSending] = useState (false)
|
||||
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 (() => {
|
||||
const nameRaw = localStorage.getItem ('name')
|
||||
nameRaw && setName (nameRaw)
|
||||
|
||||
const passRaw = localStorage.getItem ('password')
|
||||
passRaw && setName (passRaw)
|
||||
|
||||
const fetchThread = async () => {
|
||||
try
|
||||
{
|
||||
@@ -29,16 +117,17 @@ export default () => {
|
||||
setThread (null)
|
||||
}
|
||||
}
|
||||
fetchThread ()
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const fetchPosts = async () => {
|
||||
setLoading (true)
|
||||
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[]
|
||||
setPosts (data.map (p => ({
|
||||
...p,
|
||||
createdAt: (new Date (p.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') })))
|
||||
setPosts (data.map (reformPost))
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -46,10 +135,8 @@ export default () => {
|
||||
}
|
||||
setLoading (false)
|
||||
}
|
||||
|
||||
fetchThread ()
|
||||
fetchPosts ()
|
||||
}, [])
|
||||
}, [sortKey])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(thread))
|
||||
@@ -59,70 +146,148 @@ export default () => {
|
||||
|
||||
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...' : (
|
||||
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>))}
|
||||
</>)
|
||||
: 'レスないよ(笑).')}
|
||||
</>)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user