コミットを比較
13 コミット
f806622beb
..
main
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| fed0f314e2 | |||
| f18684cfd9 | |||
| 37b9f32626 | |||
| d8fd7ca124 | |||
| 5239dc5967 | |||
| e6eeb88c14 | |||
| 235814aff7 | |||
| 22a55f265c | |||
| f1770b31f5 | |||
| f1fde867fe | |||
| eba37b8b4d | |||
| 485b8571ab | |||
| a18ce64d7a |
@@ -24,7 +24,15 @@ class ThreadPostsController < ApplicationController
|
||||
def create
|
||||
post = @thread.posts.new(post_params)
|
||||
if post.save
|
||||
render json: post, status: :created
|
||||
post.thread.tap { |thread|
|
||||
thread.updated_at = post.created_at
|
||||
}.save!
|
||||
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 +45,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
|
||||
|
||||
生成ファイル
+450
-367
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -13,10 +13,12 @@
|
||||
"axios": "^1.10.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"classnames": "^2.5.1",
|
||||
"konva": "^9.3.22",
|
||||
"react": "^19.1.0",
|
||||
"react-accessible-accordion": "^5.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^19.0.7",
|
||||
"react-router-dom": "^7.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+37
-14
@@ -1,6 +1,11 @@
|
||||
import cn from 'classnames'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Link, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useSearchParams } from 'react-router-dom'
|
||||
|
||||
import bgmSrc from '@/assets/music.mp3'
|
||||
import ThreadListPage from '@/pages/threads/ThreadListPage'
|
||||
@@ -17,24 +22,39 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900',
|
||||
'bg-yellow-500 dark:bg-yellow-900'] as const
|
||||
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation ()
|
||||
|
||||
useEffect (() => {
|
||||
scrollTo (0, 0)
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export default () => {
|
||||
const [bgm] = useState (new Audio (bgmSrc))
|
||||
const bgmRef = useRef<HTMLAudioElement | null> (null)
|
||||
|
||||
const [searchParams] = useSearchParams ()
|
||||
const threadId = searchParams.get ('thread')
|
||||
|
||||
const [colourIndex, setColourIndex] = useState (0)
|
||||
const [mute, setMute] = useState (false)
|
||||
const [playing, setPlaying] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
bgm.loop = true
|
||||
bgmRef.current = new Audio (bgmSrc)
|
||||
bgmRef.current.loop = true
|
||||
bgmRef.current.volume = 1
|
||||
|
||||
const playBGM = async () => {
|
||||
if (playing)
|
||||
if (playing || !(bgmRef.current))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
await bgm.play ()
|
||||
bgm.loop = true
|
||||
|
||||
await bgmRef.current.play ()
|
||||
setPlaying (true)
|
||||
}
|
||||
catch
|
||||
@@ -48,7 +68,7 @@ export default () => {
|
||||
document.addEventListener ('touchstart', playBGM)
|
||||
|
||||
const changeColour = () => {
|
||||
setColourIndex (idx => Math.floor (Math.random () * colours.length))
|
||||
setColourIndex (Math.floor (Math.random () * colours.length))
|
||||
}
|
||||
changeColour ()
|
||||
const colouringInterval = setInterval (changeColour, 3000)
|
||||
@@ -60,11 +80,12 @@ export default () => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<div className={cn ('w-screen min-h-screen',
|
||||
colours[colourIndex],
|
||||
'transition-colors duration-[3s] ease-linear')}>
|
||||
<div className="mx-auto max-w-[960px]">
|
||||
<div className="mx-auto max-w-[960px] px-4">
|
||||
<header className="pt-6 mb-8">
|
||||
<h1 className="text-center">
|
||||
<Link to="/"
|
||||
@@ -80,7 +101,7 @@ export default () => {
|
||||
{playing && (
|
||||
<a href="#" onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setMute (bgm.muted = !(mute))
|
||||
bgmRef.current && setMute (bgmRef.current.muted = !(mute))
|
||||
}}>
|
||||
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
|
||||
</a>)}
|
||||
@@ -88,7 +109,9 @@ export default () => {
|
||||
</header>
|
||||
<main className="mb-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/threads" replace />} />
|
||||
<Route path="/"
|
||||
element={<Navigate to={`/threads/${ threadId ? (+threadId) + 1 : '' }`}
|
||||
replace />} />
|
||||
<Route path="/threads" element={<ThreadListPage />} />
|
||||
<Route path="/threads/:id" element={<ThreadDetailPage />} />
|
||||
</Routes>
|
||||
@@ -99,5 +122,5 @@ export default () => {
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>)
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
import cn from 'classnames'
|
||||
import Konva from 'konva'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 { ChangeEventHandler } from 'react'
|
||||
|
||||
type ImageItem = { src: string }
|
||||
|
||||
type Layer = {
|
||||
id: string
|
||||
name: string
|
||||
lines: Line[]
|
||||
history: Line[][]
|
||||
future: Line[][]
|
||||
image?: ImageItem }
|
||||
|
||||
type Line =
|
||||
| { mode: typeof Mode.Pen | typeof Mode.Rubber
|
||||
points: number[]
|
||||
stroke: string
|
||||
strokeWidth: number }
|
||||
| { mode: typeof Mode.Paint
|
||||
points: number[]
|
||||
stroke: string,
|
||||
imageSrc: string }
|
||||
|
||||
const Mode = {
|
||||
Pen: 'Pen',
|
||||
Rubber: 'Rubber',
|
||||
Paint: 'Paint' } as const
|
||||
type Mode = (typeof Mode)[keyof typeof Mode]
|
||||
|
||||
|
||||
const colourDiffMax = (c1: readonly number[], c2: readonly number[]) => (
|
||||
Math.max (...[0, 1, 2, 3].map (i => Math.abs (c1[i] - c2[i]))))
|
||||
|
||||
|
||||
const floodFill = (
|
||||
imgData: ImageData,
|
||||
sx: number,
|
||||
sy: number,
|
||||
fill: { r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number },
|
||||
tolerance: number = 16) => {
|
||||
const { data, width: W, height: H } = imgData
|
||||
const start = getRGBA (data, sx, sy, W)
|
||||
const visited = new Uint8Array (W * H)
|
||||
|
||||
if (colourDiffMax (start, [fill.r, fill.g, fill.b, fill.a]) <= tolerance)
|
||||
return
|
||||
|
||||
const stack: [number, number][] = [[sx, sy]]
|
||||
while (stack.length > 0)
|
||||
{
|
||||
const [x, y] = stack.pop ()!
|
||||
let lx = x
|
||||
while (lx >= 0
|
||||
&& !(visited[y * W + lx])
|
||||
&& colourDiffMax (getRGBA (data, lx, y, W), start) <= tolerance)
|
||||
--lx
|
||||
++lx
|
||||
let rx = x
|
||||
while (rx < W
|
||||
&& !(visited[y * W + rx])
|
||||
&& colourDiffMax (getRGBA (data, rx, y, W), start) <= tolerance)
|
||||
++rx
|
||||
for (let i = lx; i < rx; ++i)
|
||||
{
|
||||
const idx = y * W + i
|
||||
visited[idx] = 1
|
||||
const p = idx * 4
|
||||
data[p] = fill.r
|
||||
data[p + 1] = fill.g
|
||||
data[p + 2] = fill.b
|
||||
data[p + 3] = fill.a
|
||||
if (y > 0
|
||||
&& !(visited[(y - 1) * W + i])
|
||||
&& colourDiffMax (getRGBA (data, i, y - 1, W), start) <= tolerance)
|
||||
stack.push ([i, y - 1])
|
||||
if (y < H - 1
|
||||
&& !(visited[(y + 1) * W + i])
|
||||
&& colourDiffMax (getRGBA (data, i, y + 1, W), start) <= tolerance)
|
||||
stack.push ([i, y + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getRGBA = (data: Uint8ClampedArray, x: number, y: number, w: number) => {
|
||||
const i = (y * w + x) * 4
|
||||
return [data[i], data[i + 1], data[i + 2], data[i + 3]] as const
|
||||
}
|
||||
|
||||
|
||||
const hexToRgba = (hex: string, alpha = 255): [number, number, number, number] => {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec (hex)
|
||||
const n = parseInt (m![1], 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, alpha]
|
||||
}
|
||||
|
||||
|
||||
const isLayer = (obj: unknown): obj is Layer => (
|
||||
typeof obj === 'object'
|
||||
&& obj !== null
|
||||
&& typeof (obj as Layer).id === 'string'
|
||||
&& typeof (obj as Layer).name === 'string'
|
||||
&& Array.isArray ((obj as Layer).lines)
|
||||
&& Array.isArray ((obj as Layer).history)
|
||||
&& Array.isArray ((obj as Layer).future))
|
||||
|
||||
|
||||
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>> ({ })
|
||||
const stageRef = useRef<Konva.Stage | null> (null)
|
||||
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null> (null)
|
||||
const [colour, setColour] = useState ('#000000')
|
||||
const [images, setImages] = useState<Record<string, HTMLImageElement>> ({ })
|
||||
const [layerCnt, setLayerCnt] = useState (0)
|
||||
const [layers, setLayers] = useState<Layer[]> ([])
|
||||
const [mode, setMode] = useState<Mode> (Mode.Pen)
|
||||
const [pointSize, setPointSize] = useState (3)
|
||||
const [singleLayer, setSingleLayer] = useState (false)
|
||||
const [stageHeight, setStageHeight] = useState (480)
|
||||
const [stageWidth, setStageWidth] = useState (480)
|
||||
|
||||
const activeLayer = layers.find (l => l.id === activeLayerId)
|
||||
|
||||
const handleMouseDown = (ev: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
if (!(activeLayer) || (mode !== Mode.Pen && mode !== Mode.Rubber))
|
||||
return
|
||||
|
||||
drawingRef.current = true
|
||||
|
||||
updateActiveLayerHistory ([...activeLayer.history, activeLayer.lines])
|
||||
updateActiveLayerFuture ([])
|
||||
|
||||
const pos = ev.target.getStage ()?.getPointerPosition ()
|
||||
if (!(pos))
|
||||
return
|
||||
|
||||
const lines: Line[] = [
|
||||
...activeLayer.lines,
|
||||
{ mode,
|
||||
points: [pos.x, pos.y],
|
||||
stroke: colour,
|
||||
strokeWidth: pointSize }]
|
||||
updateActiveLayerLines (lines)
|
||||
}
|
||||
|
||||
const handleMouseMove = (ev: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
if (!(drawingRef.current) || !(activeLayer))
|
||||
return
|
||||
|
||||
const stage = ev.target.getStage ()
|
||||
const point = stage?.getPointerPosition ()
|
||||
if (!(point))
|
||||
return
|
||||
|
||||
const lastLine = activeLayer.lines[activeLayer.lines.length - 1]
|
||||
const updatedLine = { ...lastLine,
|
||||
points: [...lastLine.points, point.x, point.y] }
|
||||
const newLines = [...activeLayer.lines.slice (0, -1), updatedLine]
|
||||
updateActiveLayerLines (newLines)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
drawingRef.current = false
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
if (!(activeLayer) || activeLayer.lines.length === 0)
|
||||
return
|
||||
|
||||
const prev = activeLayer.history[activeLayer.history.length - 1]
|
||||
updateActiveLayerFuture ([...activeLayer.future, activeLayer.lines])
|
||||
updateActiveLayerLines (prev)
|
||||
updateActiveLayerHistory (activeLayer.history.slice (0, -1))
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (!(activeLayer) || activeLayer.future.length === 0)
|
||||
return
|
||||
|
||||
const next = activeLayer.future[activeLayer.future.length - 1]
|
||||
updateActiveLayerHistory ([...activeLayer.history, activeLayer.lines])
|
||||
updateActiveLayerLines (next)
|
||||
updateActiveLayerFuture (activeLayer.future.slice (0, -1))
|
||||
}
|
||||
|
||||
const handleImportImage: ChangeEventHandler<HTMLInputElement> = ev => {
|
||||
const file = ev.target.files?.[0]
|
||||
if (!(file))
|
||||
return
|
||||
|
||||
const reader = new FileReader ()
|
||||
reader.onload = () => {
|
||||
const src = reader.result?.toString ()
|
||||
if (!(src))
|
||||
return
|
||||
addLayer ({ src })
|
||||
}
|
||||
reader.readAsDataURL (file)
|
||||
|
||||
if (fileInputRef.current)
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handlePaint = (ev: Konva.KonvaEventObject<PointerEvent | MouseEvent | TouchEvent>) => {
|
||||
const layer = activeLayer
|
||||
if (!(layer) || !(layersRef.current?.[layer.id]))
|
||||
return
|
||||
|
||||
const pos = ev.target.getStage ()?.getPointerPosition ()
|
||||
if (!(pos))
|
||||
return
|
||||
|
||||
const stage = layersRef.current[layer.id]
|
||||
if (!(stage))
|
||||
return
|
||||
|
||||
if (drawingRef.current)
|
||||
return
|
||||
|
||||
drawingRef.current = true
|
||||
|
||||
const off = stage.toCanvas ({ pixelRatio: 1 })
|
||||
const ctx = off.getContext ('2d')!
|
||||
const imgData = ctx.getImageData (0, 0, off.width, off.height)
|
||||
|
||||
const [r, g, b, a] = hexToRgba (colour, 255)
|
||||
floodFill (imgData, Math.floor (pos.x), Math.floor (pos.y), { r, g, b, a })
|
||||
ctx.putImageData (imgData, 0, 0)
|
||||
|
||||
updateActiveLayerHistory ([...layer.history, layer.lines])
|
||||
updateActiveLayerFuture ([])
|
||||
|
||||
setLayers (prev => prev.map (l => l.id === activeLayerId ? ({
|
||||
...l,
|
||||
lines: [...l.lines,
|
||||
{ mode: Mode.Paint,
|
||||
points: [pos.x, pos.y],
|
||||
stroke: colour,
|
||||
imageSrc: off.toDataURL ('image/png') }] }) : l))
|
||||
|
||||
drawingRef.current = false
|
||||
}
|
||||
|
||||
const updateActiveLayerLines = (lines: Line[]) => {
|
||||
setLayers (prev => (
|
||||
prev.map (layer => (layer.id === activeLayerId
|
||||
? { ...layer, lines }
|
||||
: layer))))
|
||||
}
|
||||
|
||||
const updateActiveLayerHistory = (history: Line[][]) => {
|
||||
setLayers (prev => (
|
||||
prev.map (layer => (layer.id === activeLayerId
|
||||
? { ...layer, history }
|
||||
: layer))))
|
||||
}
|
||||
|
||||
const updateActiveLayerFuture = (future: Line[][]) => {
|
||||
setLayers (prev => (
|
||||
prev.map (layer => (layer.id === activeLayerId
|
||||
? { ...layer, future }
|
||||
: layer))))
|
||||
}
|
||||
|
||||
const addLayer = (image?: ImageItem) => {
|
||||
const layer: Layer = { id: nanoid (),
|
||||
name: `Layer ${ layerCnt + 1 }`,
|
||||
lines: [],
|
||||
history: [],
|
||||
future: [],
|
||||
image }
|
||||
setLayers(prev => [...prev, layer])
|
||||
setActiveLayerId (layer.id)
|
||||
setLayerCnt (n => n + 1)
|
||||
|
||||
return layer
|
||||
}
|
||||
|
||||
const canMoveLayer = (direction: number) => {
|
||||
const idx = layers.findIndex (l => l.id === activeLayerId)
|
||||
if (idx < 0)
|
||||
return false
|
||||
|
||||
const target = idx + direction
|
||||
return 0 <= target && target < layers.length
|
||||
}
|
||||
|
||||
const moveLayer = (direction: number) => {
|
||||
if (!(canMoveLayer (direction)))
|
||||
return
|
||||
|
||||
const idx = layers.findIndex (l => l.id === activeLayerId)
|
||||
const target = idx + direction
|
||||
const next = [...layers]
|
||||
const [item] = next.splice (idx, 1)
|
||||
next.splice (target, 0, item)
|
||||
setLayers(next)
|
||||
}
|
||||
|
||||
const clearCanvas = () => {
|
||||
const layer: Layer = { id: nanoid (),
|
||||
name: `Layer 1`,
|
||||
lines: [],
|
||||
history: [],
|
||||
future: [] }
|
||||
setLayers([layer])
|
||||
setActiveLayerId (layer.id)
|
||||
setLayerCnt (1)
|
||||
setStageWidth (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 (() => {
|
||||
try
|
||||
{
|
||||
const raw = localStorage.getItem ('paint')
|
||||
if (!(raw))
|
||||
throw new Error
|
||||
|
||||
const parsed: unknown = JSON.parse (raw)
|
||||
if (!(Array.isArray (parsed)))
|
||||
throw new Error
|
||||
|
||||
const restored: Layer[] = parsed.filter (isLayer)
|
||||
if (restored.length === 0)
|
||||
throw new Error
|
||||
|
||||
setLayers (restored)
|
||||
setActiveLayerId (restored[0].id)
|
||||
setLayerCnt (restored.length)
|
||||
}
|
||||
catch
|
||||
{
|
||||
clearCanvas ()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
localStorage.setItem ('paint', JSON.stringify (layers))
|
||||
|
||||
layers.forEach (layer => {
|
||||
if (layer.image && !(layer.id in images))
|
||||
{
|
||||
const image = new window.Image
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.src = layer.image.src
|
||||
image.onload = () => {
|
||||
setImages (prev => ({ ...prev, [layer.id]: image }))
|
||||
}
|
||||
}
|
||||
|
||||
layer.lines.forEach (line => {
|
||||
if (line.mode === Mode.Paint && !(line.imageSrc in images))
|
||||
{
|
||||
const image = new window.Image
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.src = line.imageSrc
|
||||
image.onload = () => {
|
||||
setImages (prev => ({ ...prev, [line.imageSrc]: image }))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [layers])
|
||||
|
||||
useImperativeHandle (ref, () => ({
|
||||
exportAll: async () => ({ dataURL: await exportAllLayers (),
|
||||
width: stageWidth,
|
||||
height: stageHeight }),
|
||||
clear: clearCanvas }))
|
||||
|
||||
return (
|
||||
<div className={cn (!(visible) && 'hidden')}>
|
||||
<div>
|
||||
<div>
|
||||
<button onClick={() => {
|
||||
if (!(confirm ('作成中の絵を消してもよろしいですか?')))
|
||||
return
|
||||
|
||||
clearCanvas ()
|
||||
}}>
|
||||
初期化
|
||||
</button>
|
||||
|
||||
<button onClick={() => fileInputRef.current?.click ()}>
|
||||
画像
|
||||
</button>
|
||||
<input ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImportImage} />
|
||||
|
||||
<button onClick={() => {
|
||||
const stage = stageRef.current
|
||||
if (!(stage))
|
||||
return
|
||||
const mimeType = 'image/png'
|
||||
const dataURL = stage.toDataURL ({ mimeType, pixelRatio: 2 })
|
||||
|
||||
const $a = document.createElement ('a')
|
||||
$a.href = dataURL
|
||||
$a.download = `kekec-bbs_${ (new Date).toLocaleString ('ja-JP-u-ca-japanese').replaceAll (/[\/:]/g, '-').replaceAll (/\s/g, '_') }.png`
|
||||
document.body.appendChild ($a)
|
||||
$a.click ()
|
||||
$a.remove ()
|
||||
}}>
|
||||
保存
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
幅({stageWidth} px):
|
||||
<input type="range"
|
||||
min={32}
|
||||
max={640}
|
||||
value={stageWidth}
|
||||
onChange={ev => setStageWidth (+ev.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
高さ({stageHeight} px):
|
||||
<input type="range"
|
||||
min={24}
|
||||
max={480}
|
||||
value={stageHeight}
|
||||
onChange={ev => setStageHeight (+ev.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>モード:</label>
|
||||
<label>
|
||||
<input type="radio"
|
||||
name="mode"
|
||||
value="pen"
|
||||
checked={mode === Mode.Pen}
|
||||
onChange={() => setMode (Mode.Pen)} />
|
||||
ペン
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio"
|
||||
name="mode"
|
||||
value="rubber"
|
||||
checked={mode === Mode.Rubber}
|
||||
onChange={() => setMode (Mode.Rubber)} />
|
||||
消しゴム
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio"
|
||||
name="mode"
|
||||
value="paint"
|
||||
checked={mode === Mode.Paint}
|
||||
onChange={() => setMode (Mode.Paint)} />
|
||||
塗りつぶし
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onClick={handleUndo}
|
||||
disabled={!(activeLayer) || activeLayer.lines.length === 0}>
|
||||
<FaUndo />
|
||||
</button>
|
||||
<button onClick={handleRedo}
|
||||
disabled={!(activeLayer) || activeLayer.future.length === 0}>
|
||||
<FaRedo />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>色:</label>
|
||||
<input type="color"
|
||||
value={colour}
|
||||
onChange={ev => setColour (ev.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>サイズ:</label>
|
||||
<input type="range"
|
||||
min={1}
|
||||
max={57}
|
||||
value={pointSize}
|
||||
onChange={ev => setPointSize (+ev.target.value)} />
|
||||
{pointSize}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>レイア:</label>
|
||||
{layers.map (layer => (
|
||||
<label key={layer.id}>
|
||||
<input type="radio"
|
||||
name="layer"
|
||||
value={layer.id}
|
||||
checked={layer.id === activeLayerId}
|
||||
onChange={() => setActiveLayerId (layer.id)} />
|
||||
{layer.name}
|
||||
</label>))}
|
||||
<button disabled={layers.length <= 1} onClick={() => {
|
||||
if (layers.length <= 1)
|
||||
{
|
||||
alert ('何を消そうというのだ.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(confirm ('選択中のレイアを削除するがよろしいか?')))
|
||||
return
|
||||
|
||||
let idx = layers.findIndex (l => l.id === activeLayerId) - 1
|
||||
if (idx < 0)
|
||||
idx = 0
|
||||
const newLayers = layers.filter (l => l.id !== activeLayerId)
|
||||
setActiveLayerId (newLayers[idx].id)
|
||||
setLayers (newLayers)
|
||||
}}>
|
||||
削除
|
||||
</button>
|
||||
<button disabled={!(canMoveLayer (-1))} onClick={() => {
|
||||
moveLayer (-1)
|
||||
}}>
|
||||
下へ
|
||||
</button>
|
||||
<button disabled={!(canMoveLayer (1))} onClick={() => {
|
||||
moveLayer (1)
|
||||
}}>
|
||||
上へ
|
||||
</button>
|
||||
<button onClick={() => addLayer ()}>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
checked={singleLayer}
|
||||
onChange={ev => setSingleLayer (ev.target.checked)} />
|
||||
レイアを分けて表示
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<div className="border border-black dark:border-white">
|
||||
<Stage className="touch-none"
|
||||
ref={node => {
|
||||
stageRef.current = node
|
||||
}}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
onMouseDown={mode === Mode.Paint ? handlePaint : handleMouseDown}
|
||||
onMouseMove={mode === Mode.Paint ? undefined : handleMouseMove}
|
||||
onMouseUp={mode === Mode.Paint ? undefined : handleMouseUp}
|
||||
onTouchStart={mode === Mode.Paint ? handlePaint : handleMouseDown}
|
||||
onTouchMove={mode === Mode.Paint ? undefined : handleMouseMove}
|
||||
onTouchEnd={mode === Mode.Paint ? undefined : handleMouseUp}>
|
||||
<Layer>
|
||||
<Rect x={0}
|
||||
y={0}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
fill="white"
|
||||
listening={false} />
|
||||
</Layer>
|
||||
|
||||
{layers.map (layer => (!(singleLayer) || layer.id === activeLayerId) && (
|
||||
<Layer key={layer.id} ref={node => {
|
||||
if (node)
|
||||
layersRef.current = { ...layersRef.current, [layer.id]: node }
|
||||
}}>
|
||||
{images[layer.id] && (
|
||||
<Image image={images[layer.id]}
|
||||
x={0}
|
||||
y={0}
|
||||
scaleX={stageWidth / images[layer.id].width}
|
||||
scaleY={stageHeight / images[layer.id].height} />)}
|
||||
|
||||
{layer.lines.map ((line, i) => {
|
||||
switch (line.mode)
|
||||
{
|
||||
case Mode.Pen:
|
||||
case Mode.Rubber:
|
||||
return (
|
||||
<Line key={i}
|
||||
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'} />)
|
||||
case Mode.Paint:
|
||||
return (
|
||||
<Image key={i}
|
||||
image={images[line.imageSrc]}
|
||||
x={0}
|
||||
y={0}
|
||||
scaleX={1}
|
||||
scaleY={1} />)
|
||||
}
|
||||
})}
|
||||
</Layer>))}
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
})
|
||||
+8
-4
@@ -1,6 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
import App from '@/App.tsx'
|
||||
import '@/index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>)
|
||||
|
||||
@@ -1,46 +1,239 @@
|
||||
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 [loading, setLoading] = useState (true)
|
||||
const [posts, setPosts] = useState<Post[]> ([])
|
||||
const canvasRef = useRef<ThreadCanvasHandle> (null)
|
||||
|
||||
useEffect (() => {
|
||||
const fetchPosts = async () => {
|
||||
setLoading (true)
|
||||
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 res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`)
|
||||
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') })))
|
||||
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 && setPassword (passRaw)
|
||||
|
||||
const fetchThread = async () => {
|
||||
try
|
||||
{
|
||||
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }`)
|
||||
setThread (toCamel (res.data as any, { deep: true }) as Thread)
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
setThread (null)
|
||||
}
|
||||
}
|
||||
fetchThread ()
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const fetchPosts = async () => {
|
||||
try
|
||||
{
|
||||
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 (reformPost))
|
||||
}
|
||||
catch
|
||||
{
|
||||
setPosts ([])
|
||||
}
|
||||
setLoading (false)
|
||||
}
|
||||
|
||||
setPosts ([])
|
||||
fetchPosts ()
|
||||
}, [])
|
||||
}, [sortKey])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(thread))
|
||||
return
|
||||
document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板`
|
||||
}, [thread])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 p-3 m-4
|
||||
border border-gray-400 rounded-xl
|
||||
mb-8">
|
||||
<div className="px-2 py-1 text-bold">
|
||||
{thread?.name}
|
||||
</div>
|
||||
{thread?.description && (
|
||||
<div className="px-4 my-2">
|
||||
{thread.description.replaceAll ('\r\n', '\n')
|
||||
.replaceAll ('\r', '\n')
|
||||
.split ('\n')
|
||||
.map ((l, i) => <p key={i}>{l}</p>)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<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 className="bg-white dark:bg-gray-800 p-3 m-4
|
||||
? (
|
||||
<>
|
||||
<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">
|
||||
@@ -55,7 +248,7 @@ export default () => {
|
||||
try
|
||||
{
|
||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
|
||||
setPosts (prev => prev.map (p => (p.id == post.id
|
||||
setPosts (prev => prev.map (p => (p.id === post.id
|
||||
? { ...p, bad: p.bad + 1 }
|
||||
: p)))
|
||||
}
|
||||
@@ -83,7 +276,7 @@ export default () => {
|
||||
try
|
||||
{
|
||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`)
|
||||
setPosts (prev => prev.map (p => (p.id == post.id
|
||||
setPosts (prev => prev.map (p => (p.id === post.id
|
||||
? { ...p, good: p.good + 1 }
|
||||
: p)))
|
||||
}
|
||||
@@ -95,10 +288,21 @@ export default () => {
|
||||
{post.good} <FaThumbsUp className="inline" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-white inline-block">
|
||||
{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>))
|
||||
</div>)}
|
||||
</>)}
|
||||
</div>))}
|
||||
</>)
|
||||
: 'レスないよ(笑).')}
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import cn from 'classnames'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Accordion,
|
||||
AccordionItem,
|
||||
@@ -41,6 +40,7 @@ export default () => {
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
document.title = 'キケッツチャンネル お絵描き掲示板'
|
||||
fetchThreads ()
|
||||
}, [])
|
||||
|
||||
@@ -66,7 +66,8 @@ export default () => {
|
||||
{loading ? 'Loading...' : (
|
||||
threads.length > 0
|
||||
? threads.map (thread => (
|
||||
<div className="bg-white dark:bg-gray-800 p-3 m-4
|
||||
<div key={thread.id}
|
||||
className="bg-white dark:bg-gray-800 p-3 m-4
|
||||
border border-gray-400 rounded-xl">
|
||||
<div>
|
||||
<Link to={`/threads/${ thread.id }`}>
|
||||
@@ -76,7 +77,7 @@ export default () => {
|
||||
{thread.description?.replaceAll ('\r\n', '\n')
|
||||
.replaceAll ('\r', '\n')
|
||||
.split ('\n')
|
||||
.map (l => <p>{l}</p>)}
|
||||
.map ((l, i) => <p key={i}>{l}</p>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 justify-between text-sm
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export type Post = {
|
||||
id: number
|
||||
threadId: number
|
||||
postNo: number
|
||||
name: string | null
|
||||
message: string | null
|
||||
imageUrl: string | null
|
||||
held: boolean
|
||||
sensitive: boolean
|
||||
good: number
|
||||
bad: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null }
|
||||
|
||||
export type Thread = {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
postCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null }
|
||||
新しい課題から参照
ユーザをブロックする