This commit is contained in:
Generated
+98
@@ -11,10 +11,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": {
|
||||
@@ -1477,6 +1479,15 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||
"integrity": "sha512-+WHarFkJevhH1s655qeeSEf/yxFST0dVRsmSqUgxG8mMOKqycgYBv2wVpyubBY7MX8KiX5FQ03rNIwrxfm7Bmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
||||
@@ -3141,6 +3152,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/its-fine/node_modules/@types/react-reconciler": {
|
||||
"version": "0.28.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
|
||||
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@@ -3244,6 +3276,26 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "9.3.22",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz",
|
||||
"integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/lavrton"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/konva"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -3902,6 +3954,52 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-konva": {
|
||||
"version": "19.0.7",
|
||||
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.0.7.tgz",
|
||||
"integrity": "sha512-uYWCpSv4ajLymTh8S8fV9396fHDX7eDTWiLGkYlBuawud5MoNiuGjapPhA5Avdy/Jfh9P2KaWuNf4i9PI1F9HQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/lavrton"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/konva"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
"its-fine": "^2.0.0",
|
||||
"react-reconciler": "0.32.0",
|
||||
"scheduler": "0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"react-dom": "^18.3.1 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||
"integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+21
-9
@@ -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 { BrowserRouter,
|
||||
Link,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation } from 'react-router-dom'
|
||||
|
||||
import bgmSrc from '@/assets/music.mp3'
|
||||
import ThreadListPage from '@/pages/threads/ThreadListPage'
|
||||
@@ -18,13 +23,16 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900',
|
||||
|
||||
|
||||
export default () => {
|
||||
const [bgm] = useState (new Audio (bgmSrc))
|
||||
const bgmRef = useRef<HTMLAudioElement | null> (null)
|
||||
|
||||
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)
|
||||
@@ -32,9 +40,7 @@ export default () => {
|
||||
|
||||
try
|
||||
{
|
||||
await bgm.play ()
|
||||
bgm.loop = true
|
||||
|
||||
await bgmRef.current.play ()
|
||||
setPlaying (true)
|
||||
}
|
||||
catch
|
||||
@@ -64,7 +70,7 @@ export default () => {
|
||||
<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 +86,7 @@ export default () => {
|
||||
{playing && (
|
||||
<a href="#" onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setMute (bgm.muted = !(mute))
|
||||
setMute (bgmRef.current.muted = !(mute))
|
||||
}}>
|
||||
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
|
||||
</a>)}
|
||||
@@ -88,6 +94,12 @@ export default () => {
|
||||
</header>
|
||||
<main className="mb-8">
|
||||
<Routes>
|
||||
{() => {
|
||||
const { pathname } = useLocation ()
|
||||
useEffect (() => {
|
||||
scrollTo (0, 0)
|
||||
}, [pathname])
|
||||
}}
|
||||
<Route path="/" element={<Navigate to="/threads" replace />} />
|
||||
<Route path="/threads" element={<ThreadListPage />} />
|
||||
<Route path="/threads/:id" element={<ThreadDetailPage />} />
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRedo, FaUndo } from 'react-icons/fa'
|
||||
import { Layer, Line, Rect, Stage, Image } from 'react-konva'
|
||||
|
||||
import type { KonvaEventObject } from '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: Mode
|
||||
points: number[]
|
||||
stroke: string
|
||||
strokeWidth: number }
|
||||
|
||||
const Mode = {
|
||||
Pen: Symbol (),
|
||||
Rubber: Symbol (),
|
||||
Image: Symbol () } as const
|
||||
type Mode = keyof Mode
|
||||
|
||||
|
||||
export default () => {
|
||||
const drawingRef = useRef (false)
|
||||
const fileInputRef = useRef<HTMLInputElement> (null)
|
||||
const stageRef = useRef<Stage | null> (null)
|
||||
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null> (null)
|
||||
const [colour, setColour] = useState ('#000000')
|
||||
const [images, setImages] = useState<Record<string, window.Image[]>> ({ })
|
||||
const [layers, setLayers] = useState<Layer[]> ([])
|
||||
const [layerCnt, setLayerCnt] = useState (0)
|
||||
const [mode, setMode] = useState<Mode> (Mode.Pen)
|
||||
const [pointSize, setPointSize] = useState (3)
|
||||
const [stageWidth, setStageWidth] = useState (480)
|
||||
const [stageHeight, setStageHeight] = useState (480)
|
||||
|
||||
const activeLayer = layers.find (l => l.id === activeLayerId)
|
||||
|
||||
const handleMouseDown = (ev: KonvaEventObject<MouseEvent>) => {
|
||||
drawingRef.current = true
|
||||
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: KonvaEventObject<MouseEvent>) => {
|
||||
if (!(drawingRef.current))
|
||||
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.lines.length === 0)
|
||||
return
|
||||
const newLines = [...activeLayer.lines]
|
||||
const last = newLines.pop()!
|
||||
updateActiveLayerHistory ([...activeLayer.history, activeLayer.lines])
|
||||
updateActiveLayerFuture ([])
|
||||
updateActiveLayerLines (newLines)
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (activeLayer.history.length === 0)
|
||||
return
|
||||
const prev = activeLayer.history[activeLayer.history.length - 1]
|
||||
updateActiveLayerLines (prev)
|
||||
updateActiveLayerHistory (activeLayer.history.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 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 moveLayer = (direction: number) => {
|
||||
setLayers(prev => {
|
||||
const idx = prev.findIndex (l => l.id === activeLayerId)
|
||||
if (idx < 0)
|
||||
return prev
|
||||
const target = idx + direction
|
||||
if (target < 0 || target >= prev.length)
|
||||
return prev
|
||||
const next = [...prev]
|
||||
const [item] = next.splice(idx, 1)
|
||||
next.splice(target, 0, item)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
try
|
||||
{
|
||||
const paint = JSON.parse (localStorage.getItem ('paint'))
|
||||
if (!(paint instanceof Array))
|
||||
throw new Error
|
||||
setLayers (paint)
|
||||
setActiveLayerId (paint[0].id)
|
||||
}
|
||||
catch
|
||||
{
|
||||
const layer: Layer = { id: nanoid (),
|
||||
name: `Layer ${ layerCnt + 1 }`,
|
||||
lines: [],
|
||||
history: [],
|
||||
future: [] }
|
||||
setLayers([layer])
|
||||
setActiveLayerId (layer.id)
|
||||
setLayerCnt (n => n + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
localStorage.setItem ('paint', JSON.stringify (layers))
|
||||
|
||||
layers.forEach (layer => {
|
||||
if (layer.image && !(layer.id in images))
|
||||
{
|
||||
const image = new window.Image
|
||||
image.src = layer.image.src
|
||||
image.onload = () => {
|
||||
setImages (prev => ({ ...prev, [layer.id]: image }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [layers])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<button onClick={() => {
|
||||
if (!(confirm ('作成中の絵を消してもよろしいですか?')))
|
||||
return
|
||||
|
||||
setLayers (layers.map (layer => ({
|
||||
...layer,
|
||||
lines: [],
|
||||
hishory: [],
|
||||
future: [] })))
|
||||
}}>
|
||||
初期化
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onClick={handleUndo} disabled={activeLayer?.lines.length === 0}>
|
||||
<FaUndo />
|
||||
</button>
|
||||
<button onClick={handleRedo} disabled={activeLayer?.history.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 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 onClick={() => {
|
||||
moveLayer (-1)
|
||||
}}>
|
||||
下へ
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
moveLayer (1)
|
||||
}}>
|
||||
上へ
|
||||
</button>
|
||||
<button onClick={() => addLayer ()}>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center mb-16">
|
||||
<div className="border border-black dark:border-white">
|
||||
<Stage ref={node => {
|
||||
stageRef.current = node
|
||||
return node
|
||||
}}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchStart={handleMouseDown}
|
||||
onTouchMove={handleMouseMove}
|
||||
onTouchEnd={handleMouseUp}>
|
||||
<Layer>
|
||||
<Rect x={0}
|
||||
y={0}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
fill="white" />
|
||||
</Layer>
|
||||
|
||||
{layers.map (layer => (
|
||||
<Layer key={layer.id}>
|
||||
{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) => (
|
||||
<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'} />))}
|
||||
</Layer>))}
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import ThreadCanvas from '@/components/threads/ThreadCanvas'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
|
||||
|
||||
@@ -12,8 +13,21 @@ export default () => {
|
||||
|
||||
const [loading, setLoading] = useState (true)
|
||||
const [posts, setPosts] = useState<Post[]> ([])
|
||||
const [thread, setThread] = useState<Thread | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPosts = async () => {
|
||||
setLoading (true)
|
||||
try
|
||||
@@ -26,21 +40,29 @@ export default () => {
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
setPosts ([])
|
||||
}
|
||||
setLoading (false)
|
||||
}
|
||||
|
||||
setPosts ([])
|
||||
fetchThread ()
|
||||
fetchPosts ()
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(thread))
|
||||
return
|
||||
document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板`
|
||||
}, [thread])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreadCanvas />
|
||||
{loading ? 'Loading...' : (
|
||||
posts.length > 0
|
||||
? posts.map (post => (
|
||||
<div className="bg-white dark:bg-gray-800 p-3 m-4
|
||||
<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">
|
||||
@@ -55,7 +77,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 +105,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,7 +117,7 @@ export default () => {
|
||||
{post.good} <FaThumbsUp className="inline" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-white inline-block">
|
||||
<div className="bg-white inline-block border border-black dark:border-white">
|
||||
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
|
||||
</div>
|
||||
</div>))
|
||||
|
||||
@@ -41,6 +41,7 @@ export default () => {
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
document.title = 'キケッツチャンネル お絵描き掲示板'
|
||||
fetchThreads ()
|
||||
}, [])
|
||||
|
||||
@@ -66,7 +67,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 +78,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
|
||||
|
||||
Reference in New Issue
Block a user