みてるぞ 3 days ago
parent
commit
5239dc5967
4 changed files with 344 additions and 80 deletions
  1. +9
    -2
      backend/app/controllers/thread_posts_controller.rb
  2. +9
    -0
      backend/app/models/post.rb
  3. +89
    -6
      frontend/src/components/threads/ThreadCanvas.tsx
  4. +237
    -72
      frontend/src/pages/threads/ThreadDetailPage.tsx

+ 9
- 2
backend/app/controllers/thread_posts_controller.rb View File

@@ -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

+ 9
- 0
backend/app/models/post.rb View File

@@ -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

+ 89
- 6
frontend/src/components/threads/ThreadCanvas.tsx View File

@@ -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>)
}
})

+ 237
- 72
frontend/src/pages/threads/ThreadDetailPage.tsx View File

@@ -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>))}
</>)
: 'レスないよ(笑).')} : 'レスないよ(笑).')}
</>) </>)
} }

Loading…
Cancel
Save