diff --git a/backend/app/controllers/thread_posts_controller.rb b/backend/app/controllers/thread_posts_controller.rb index b87b7b5..d17340e 100644 --- a/backend/app/controllers/thread_posts_controller.rb +++ b/backend/app/controllers/thread_posts_controller.rb @@ -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 diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 032ccdd..0ee3cb2 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -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 diff --git a/frontend/src/components/threads/ThreadCanvas.tsx b/frontend/src/components/threads/ThreadCanvas.tsx index ff39086..5560142 100644 --- a/frontend/src/components/threads/ThreadCanvas.tsx +++ b/frontend/src/components/threads/ThreadCanvas.tsx @@ -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 (({ visible }, ref) => { const drawingRef = useRef (false) const fileInputRef = useRef (null) const layersRef = useRef> ({ }) @@ -321,6 +335,69 @@ export default () => { setStageHeight (480) } + const exportAllLayers = async (): Promise => { + 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 ( -
+
-
+
{ @@ -609,4 +692,4 @@ export default () => {
) -} +}) diff --git a/frontend/src/pages/threads/ThreadDetailPage.tsx b/frontend/src/pages/threads/ThreadDetailPage.tsx index a22eaa1..f2a60a0 100644 --- a/frontend/src/pages/threads/ThreadDetailPage.tsx +++ b/frontend/src/pages/threads/ThreadDetailPage.tsx @@ -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 (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 ([]) + const [sending, setSending] = useState (false) const [thread, setThread] = useState (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 ( <> - +
+
+ + setName (ev.target.value)} /> +
+
+ + setPassword (ev.target.value)} /> +
+
+ +