@@ -11,10 +11,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": { | ||||
@@ -1477,6 +1479,15 @@ | |||||
"@types/react": "^19.0.0" | "@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": { | "node_modules/@typescript-eslint/eslint-plugin": { | ||||
"version": "8.37.0", | "version": "8.37.0", | ||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", | "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", | ||||
@@ -3141,6 +3152,27 @@ | |||||
"dev": true, | "dev": true, | ||||
"license": "ISC" | "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": { | "node_modules/jackspeak": { | ||||
"version": "3.4.3", | "version": "3.4.3", | ||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", | "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", | ||||
@@ -3244,6 +3276,26 @@ | |||||
"json-buffer": "3.0.1" | "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": { | "node_modules/levn": { | ||||
"version": "0.4.1", | "version": "0.4.1", | ||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | ||||
@@ -3902,6 +3954,52 @@ | |||||
"react": "*" | "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": { | "node_modules/react-refresh": { | ||||
"version": "0.17.0", | "version": "0.17.0", | ||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", | "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", | ||||
@@ -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": { | ||||
@@ -1,6 +1,11 @@ | |||||
import cn from 'classnames' | 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 bgmSrc from '@/assets/music.mp3' | ||||
import ThreadListPage from '@/pages/threads/ThreadListPage' | import ThreadListPage from '@/pages/threads/ThreadListPage' | ||||
@@ -18,13 +23,16 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900', | |||||
export default () => { | export default () => { | ||||
const [bgm] = useState (new Audio (bgmSrc)) | |||||
const bgmRef = useRef<HTMLAudioElement | null> (null) | |||||
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) | ||||
@@ -32,9 +40,7 @@ export default () => { | |||||
try | try | ||||
{ | { | ||||
await bgm.play () | |||||
bgm.loop = true | |||||
await bgmRef.current.play () | |||||
setPlaying (true) | setPlaying (true) | ||||
} | } | ||||
catch | catch | ||||
@@ -64,7 +70,7 @@ export default () => { | |||||
<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 +86,7 @@ export default () => { | |||||
{playing && ( | {playing && ( | ||||
<a href="#" onClick={ev => { | <a href="#" onClick={ev => { | ||||
ev.preventDefault () | ev.preventDefault () | ||||
setMute (bgm.muted = !(mute)) | |||||
setMute (bgmRef.current.muted = !(mute)) | |||||
}}> | }}> | ||||
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'} | {mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'} | ||||
</a>)} | </a>)} | ||||
@@ -88,6 +94,12 @@ export default () => { | |||||
</header> | </header> | ||||
<main className="mb-8"> | <main className="mb-8"> | ||||
<Routes> | <Routes> | ||||
{() => { | |||||
const { pathname } = useLocation () | |||||
useEffect (() => { | |||||
scrollTo (0, 0) | |||||
}, [pathname]) | |||||
}} | |||||
<Route path="/" element={<Navigate to="/threads" replace />} /> | <Route path="/" element={<Navigate to="/threads" replace />} /> | ||||
<Route path="/threads" element={<ThreadListPage />} /> | <Route path="/threads" element={<ThreadListPage />} /> | ||||
<Route path="/threads/:id" element={<ThreadDetailPage />} /> | <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 { FaThumbsDown, FaThumbsUp } from 'react-icons/fa' | ||||
import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
import ThreadCanvas from '@/components/threads/ThreadCanvas' | |||||
import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
@@ -12,8 +13,21 @@ export default () => { | |||||
const [loading, setLoading] = useState (true) | const [loading, setLoading] = useState (true) | ||||
const [posts, setPosts] = useState<Post[]> ([]) | const [posts, setPosts] = useState<Post[]> ([]) | ||||
const [thread, setThread] = useState<Thread | null> (null) | |||||
useEffect (() => { | 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 () => { | const fetchPosts = async () => { | ||||
setLoading (true) | setLoading (true) | ||||
try | try | ||||
@@ -26,21 +40,29 @@ export default () => { | |||||
} | } | ||||
catch | catch | ||||
{ | { | ||||
; | |||||
setPosts ([]) | |||||
} | } | ||||
setLoading (false) | setLoading (false) | ||||
} | } | ||||
setPosts ([]) | |||||
fetchThread () | |||||
fetchPosts () | fetchPosts () | ||||
}, []) | }, []) | ||||
useEffect (() => { | |||||
if (!(thread)) | |||||
return | |||||
document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板` | |||||
}, [thread]) | |||||
return ( | return ( | ||||
<> | <> | ||||
<ThreadCanvas /> | |||||
{loading ? 'Loading...' : ( | {loading ? 'Loading...' : ( | ||||
posts.length > 0 | posts.length > 0 | ||||
? posts.map (post => ( | ? 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 | border border-gray-400 rounded-xl | ||||
text-center"> | text-center"> | ||||
<div className="flex justify-between items-center px-2 py-1"> | <div className="flex justify-between items-center px-2 py-1"> | ||||
@@ -55,7 +77,7 @@ export default () => { | |||||
try | try | ||||
{ | { | ||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`) | 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, bad: p.bad + 1 } | ||||
: p))) | : p))) | ||||
} | } | ||||
@@ -83,7 +105,7 @@ export default () => { | |||||
try | try | ||||
{ | { | ||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`) | 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, good: p.good + 1 } | ||||
: p))) | : p))) | ||||
} | } | ||||
@@ -95,7 +117,7 @@ export default () => { | |||||
{post.good} <FaThumbsUp className="inline" /> | {post.good} <FaThumbsUp className="inline" /> | ||||
</a> | </a> | ||||
</div> | </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 }`} /> | <img src={`${ API_BASE_URL }${ post.imageUrl }`} /> | ||||
</div> | </div> | ||||
</div>)) | </div>)) | ||||
@@ -41,6 +41,7 @@ export default () => { | |||||
} | } | ||||
useEffect (() => { | useEffect (() => { | ||||
document.title = 'キケッツチャンネル お絵描き掲示板' | |||||
fetchThreads () | fetchThreads () | ||||
}, []) | }, []) | ||||
@@ -66,7 +67,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 +78,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 | ||||