コミットを比較

...

13 コミット

作成者 SHA1 メッセージ 日付
みてるぞ fed0f314e2 #3 2025-08-12 18:21:45 +09:00
みてるぞ f18684cfd9 細部 2025-08-12 17:14:55 +09:00
みてるぞ 37b9f32626 細部 2025-08-12 02:56:26 +09:00
みてるぞ d8fd7ca124 #5 2025-08-12 00:16:21 +09:00
みてるぞ 5239dc5967 #3 2025-08-11 06:10:19 +09:00
みてるぞ e6eeb88c14 #3 2025-08-10 17:19:56 +09:00
みてるぞ 235814aff7 #3 型エラー対応 2025-08-10 06:08:37 +09:00
みてるぞ 22a55f265c #3 2025-08-10 04:50:33 +09:00
みてるぞ f1770b31f5 #3 2025-08-10 02:58:23 +09:00
みてるぞ f1fde867fe #3 2025-08-10 02:39:17 +09:00
みてるぞ eba37b8b4d #3 TS エラー修正 2025-08-10 01:08:09 +09:00
みてるぞ 485b8571ab #3 サイズ変更 2025-08-09 23:28:37 +09:00
みてるぞ a18ce64d7a #3 2025-08-09 20:31:18 +09:00
10個のファイルの変更1517行の追加463行の削除
+12 -2
ファイルの表示
@@ -24,7 +24,15 @@ 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 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 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 +45,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
ファイルの表示
@@ -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
生成ファイル
+450 -367
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+2
ファイルの表示
@@ -13,10 +13,12 @@
"axios": "^1.10.0", "axios": "^1.10.0",
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"konva": "^9.3.22",
"react": "^19.1.0", "react": "^19.1.0",
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-konva": "^19.0.7",
"react-router-dom": "^7.7.0" "react-router-dom": "^7.7.0"
}, },
"devDependencies": { "devDependencies": {
+37 -14
ファイルの表示
@@ -1,6 +1,11 @@
import cn from 'classnames' import cn from 'classnames'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { BrowserRouter, Link, Routes, Route, Navigate } from 'react-router-dom' import { Link,
Navigate,
Route,
Routes,
useLocation,
useSearchParams } from 'react-router-dom'
import bgmSrc from '@/assets/music.mp3' import bgmSrc from '@/assets/music.mp3'
import ThreadListPage from '@/pages/threads/ThreadListPage' 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 'bg-yellow-500 dark:bg-yellow-900'] as const
const ScrollToTop = () => {
const { pathname } = useLocation ()
useEffect (() => {
scrollTo (0, 0)
}, [pathname])
return null
}
export default () => { 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 [colourIndex, setColourIndex] = useState (0)
const [mute, setMute] = useState (false) const [mute, setMute] = useState (false)
const [playing, setPlaying] = useState (false) const [playing, setPlaying] = useState (false)
useEffect (() => { useEffect (() => {
bgm.loop = true bgmRef.current = new Audio (bgmSrc)
bgmRef.current.loop = true
bgmRef.current.volume = 1
const playBGM = async () => { const playBGM = async () => {
if (playing) if (playing || !(bgmRef.current))
return return
try try
{ {
await bgm.play () await bgmRef.current.play ()
bgm.loop = true
setPlaying (true) setPlaying (true)
} }
catch catch
@@ -48,7 +68,7 @@ export default () => {
document.addEventListener ('touchstart', playBGM) document.addEventListener ('touchstart', playBGM)
const changeColour = () => { const changeColour = () => {
setColourIndex (idx => Math.floor (Math.random () * colours.length)) setColourIndex (Math.floor (Math.random () * colours.length))
} }
changeColour () changeColour ()
const colouringInterval = setInterval (changeColour, 3000) const colouringInterval = setInterval (changeColour, 3000)
@@ -60,11 +80,12 @@ export default () => {
}, []) }, [])
return ( return (
<BrowserRouter> <>
<ScrollToTop />
<div className={cn ('w-screen min-h-screen', <div className={cn ('w-screen min-h-screen',
colours[colourIndex], colours[colourIndex],
'transition-colors duration-[3s] ease-linear')}> '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"> <header className="pt-6 mb-8">
<h1 className="text-center"> <h1 className="text-center">
<Link to="/" <Link to="/"
@@ -80,7 +101,7 @@ export default () => {
{playing && ( {playing && (
<a href="#" onClick={ev => { <a href="#" onClick={ev => {
ev.preventDefault () ev.preventDefault ()
setMute (bgm.muted = !(mute)) bgmRef.current && setMute (bgmRef.current.muted = !(mute))
}}> }}>
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'} {mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
</a>)} </a>)}
@@ -88,7 +109,9 @@ export default () => {
</header> </header>
<main className="mb-8"> <main className="mb-8">
<Routes> <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" element={<ThreadListPage />} />
<Route path="/threads/:id" element={<ThreadDetailPage />} /> <Route path="/threads/:id" element={<ThreadDetailPage />} />
</Routes> </Routes>
@@ -99,5 +122,5 @@ export default () => {
</footer> </footer>
</div> </div>
</div> </div>
</BrowserRouter>) </>)
} }
+695
ファイルの表示
@@ -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 { createRoot } from 'react-dom/client'
import './index.css' import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(<App />) import App from '@/App.tsx'
import '@/index.css'
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>)
+277 -73
ファイルの表示
@@ -1,104 +1,308 @@
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 { API_BASE_URL } from '@/config' 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 () => { 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 [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 fetchPosts = async () => { const nameRaw = localStorage.getItem ('name')
setLoading (true) nameRaw && setName (nameRaw)
const passRaw = localStorage.getItem ('password')
passRaw && setPassword (passRaw)
const fetchThread = async () => {
try try
{ {
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`) const res = await axios.get (`${ API_BASE_URL }/threads/${ id }`)
const data = toCamel (res.data as any, { deep: true }) as Post[] setThread (toCamel (res.data as any, { deep: true }) as Thread)
setPosts (data.map (p => ({
...p,
createdAt: (new Date (p.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') })))
} }
catch 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) setLoading (false)
} }
setPosts ([])
fetchPosts () fetchPosts ()
}, []) }, [sortKey])
useEffect (() => {
if (!(thread))
return
document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板`
}, [thread])
return ( 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...' : ( {loading ? 'Loading...' : (
posts.length > 0 posts.length > 0
? posts.map (post => ( ? (
<div className="bg-white dark:bg-gray-800 p-3 m-4 <>
border border-gray-400 rounded-xl <div>
text-center"> <label>
<div className="flex justify-between items-center px-2 py-1">
<span>{post.postNo}: {post.name || '名なしさん'}</span> <select value={sortKey} onChange={ev => {
<span>{post.createdAt}</span> switch (ev.target.value)
</div> {
<div className="flex items-center px-4 pt-1 pb-3"> case Sort.Newest:
<a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap" searchParams.set ('sort', 'created_at')
href="#" searchParams.set ('order', 'desc')
onClick={async ev => { break
ev.preventDefault () case Sort.Likes:
try searchParams.set ('sort', 'score')
{ searchParams.set ('order', 'desc')
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`) break
setPosts (prev => prev.map (p => (p.id == post.id case Sort.Dislikes:
? { ...p, bad: p.bad + 1 } searchParams.set ('sort', 'score')
: p))) searchParams.set ('order', 'asc')
} break
catch case Sort.Oldest:
{ searchParams.set ('sort', 'created_at')
; searchParams.set ('order', 'asc')
} break
}}> }
<FaThumbsDown className="inline" /> {post.bad} setSearchParams (searchParams)
</a> }}>
<div className="h-2 bg-blue-600 dark:bg-blue-300" <option value={Sort.Newest}>稿</option>
style={{ width: `${ post.good + post.bad === 0 <option value={Sort.Likes}></option>
? 50 <option value={Sort.Dislikes}></option>
: post.bad * 100 / (post.good + post.bad) }%` }}> <option value={Sort.Oldest}>稿</option>
</div> </select>
<div className="h-2 bg-red-600 dark:bg-red-300" </label>
style={{ width: `${ post.good + post.bad === 0 </div>
? 50 {posts.map (post => (
: post.good * 100 / (post.good + post.bad) }%` }}> <div key={post.id}
</div> id={post.postNo.toString ()}
<a className="text-red-600 dark:text-red-300 ml-1 whitespace-nowrap" className="bg-white dark:bg-gray-800 p-3 m-4
href="#" border border-gray-400 rounded-xl
onClick={async ev => { text-center">
ev.preventDefault () <div className="flex justify-between items-center px-2 py-1">
try <span>{post.postNo}: {post.name || '名なしさん'}</span>
{ <span>{post.createdAt}</span>
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`) </div>
setPosts (prev => prev.map (p => (p.id == post.id <div className="flex items-center px-4 pt-1 pb-3">
? { ...p, good: p.good + 1 } <a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap"
: p))) href="#"
} onClick={async ev => {
catch ev.preventDefault ()
{ try
; {
} await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
}}> setPosts (prev => prev.map (p => (p.id === post.id
{post.good} <FaThumbsUp className="inline" /> ? { ...p, bad: p.bad + 1 }
</a> : p)))
</div> }
<div className="bg-white inline-block"> catch
<img src={`${ API_BASE_URL }${ post.imageUrl }`} /> {
</div> ;
</div>)) }
}}>
<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>))}
</>)
: 'レスないよ(笑).')} : 'レスないよ(笑).')}
</>) </>)
} }
+4 -3
ファイルの表示
@@ -1,6 +1,5 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys' import toCamel from 'camelcase-keys'
import cn from 'classnames'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Accordion, import { Accordion,
AccordionItem, AccordionItem,
@@ -41,6 +40,7 @@ export default () => {
} }
useEffect (() => { useEffect (() => {
document.title = 'キケッツチャンネル お絵描き掲示板'
fetchThreads () fetchThreads ()
}, []) }, [])
@@ -66,7 +66,8 @@ export default () => {
{loading ? 'Loading...' : ( {loading ? 'Loading...' : (
threads.length > 0 threads.length > 0
? threads.map (thread => ( ? 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"> border border-gray-400 rounded-xl">
<div> <div>
<Link to={`/threads/${ thread.id }`}> <Link to={`/threads/${ thread.id }`}>
@@ -76,7 +77,7 @@ export default () => {
{thread.description?.replaceAll ('\r\n', '\n') {thread.description?.replaceAll ('\r\n', '\n')
.replaceAll ('\r', '\n') .replaceAll ('\r', '\n')
.split ('\n') .split ('\n')
.map (l => <p>{l}</p>)} .map ((l, i) => <p key={i}>{l}</p>)}
</div> </div>
</div> </div>
<div className="grid grid-cols-3 justify-between text-sm <div className="grid grid-cols-3 justify-between text-sm
+23
ファイルの表示
@@ -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 }