This commit is contained in:
@@ -24,7 +24,12 @@ class ThreadPostsController < ApplicationController
|
|||||||
def create
|
def create
|
||||||
post = @thread.posts.new(post_params)
|
post = @thread.posts.new(post_params)
|
||||||
if post.save
|
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
|
else
|
||||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -37,6 +42,8 @@ class ThreadPostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def post_params
|
def post_params
|
||||||
params.require(:post).permit(:name, :body, :password)
|
params.permit(:name, :message, :password, :image).transform_values { |v|
|
||||||
|
v.presence
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ class Post < ApplicationRecord
|
|||||||
belongs_to :thread, class_name: 'Topic', foreign_key: :thread_id
|
belongs_to :thread, class_name: 'Topic', foreign_key: :thread_id
|
||||||
has_one_attached :image
|
has_one_attached :image
|
||||||
|
|
||||||
|
before_create :set_post_no
|
||||||
|
|
||||||
has_secure_password validations: false
|
has_secure_password validations: false
|
||||||
|
|
||||||
scope :active, -> { where deleted_at: nil }
|
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
|
end
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import cn from 'classnames'
|
||||||
|
import Konva from 'konva'
|
||||||
import { nanoid } from 'nanoid'
|
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 { FaRedo, FaUndo } from 'react-icons/fa'
|
||||||
import { Layer, Line, Rect, Stage, Image } from 'react-konva'
|
import { Layer, Line, Rect, Stage, Image } from 'react-konva'
|
||||||
|
|
||||||
import type Konva from 'konva'
|
|
||||||
import type { ChangeEventHandler } from 'react'
|
import type { ChangeEventHandler } from 'react'
|
||||||
|
|
||||||
type ImageItem = { src: string }
|
type ImageItem = { src: string }
|
||||||
@@ -113,7 +118,16 @@ const isLayer = (obj: unknown): obj is Layer => (
|
|||||||
&& Array.isArray ((obj as Layer).future))
|
&& 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 drawingRef = useRef (false)
|
||||||
const fileInputRef = useRef<HTMLInputElement> (null)
|
const fileInputRef = useRef<HTMLInputElement> (null)
|
||||||
const layersRef = useRef<Record<string, Konva.Layer>> ({ })
|
const layersRef = useRef<Record<string, Konva.Layer>> ({ })
|
||||||
@@ -321,6 +335,69 @@ export default () => {
|
|||||||
setStageHeight (480)
|
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 (() => {
|
useEffect (() => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -374,8 +451,14 @@ export default () => {
|
|||||||
})
|
})
|
||||||
}, [layers])
|
}, [layers])
|
||||||
|
|
||||||
|
useImperativeHandle (ref, () => ({
|
||||||
|
exportAll: async () => ({ dataURL: await exportAllLayers (),
|
||||||
|
width: stageWidth,
|
||||||
|
height: stageHeight }),
|
||||||
|
clear: clearCanvas }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={cn (!(visible) && 'hidden')}>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
@@ -544,7 +627,7 @@ export default () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="border border-black dark:border-white">
|
||||||
<Stage className="touch-none"
|
<Stage className="touch-none"
|
||||||
ref={node => {
|
ref={node => {
|
||||||
@@ -609,4 +692,4 @@ export default () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -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 => ({
|
setPosts (data.map (reformPost))
|
||||||
...p,
|
|
||||||
createdAt: (new Date (p.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') })))
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -46,10 +135,8 @@ export default () => {
|
|||||||
}
|
}
|
||||||
setLoading (false)
|
setLoading (false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchThread ()
|
|
||||||
fetchPosts ()
|
fetchPosts ()
|
||||||
}, [])
|
}, [sortKey])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(thread))
|
if (!(thread))
|
||||||
@@ -59,11 +146,78 @@ 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>
|
||||||
|
<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}
|
<div key={post.id}
|
||||||
|
id={post.postNo.toString ()}
|
||||||
className="bg-white dark:bg-gray-800 p-3 m-4
|
className="bg-white dark:bg-gray-800 p-3 m-4
|
||||||
border border-gray-400 rounded-xl
|
border border-gray-400 rounded-xl
|
||||||
text-center">
|
text-center">
|
||||||
@@ -119,10 +273,21 @@ export default () => {
|
|||||||
{post.good} <FaThumbsUp className="inline" />
|
{post.good} <FaThumbsUp className="inline" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div className="bg-white inline-block border border-black dark:border-white">
|
||||||
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
|
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
|
||||||
</div>
|
</div>)}
|
||||||
</div>))
|
</>)}
|
||||||
|
</div>))}
|
||||||
|
</>)
|
||||||
: 'レスないよ(笑).')}
|
: 'レスないよ(笑).')}
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user