diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc6b0cd..b4d90a9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b2a94c0..7782744 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c17058c..f0b548e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (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 () => {
-
+

{ {playing && ( { ev.preventDefault () - setMute (bgm.muted = !(mute)) + setMute (bgmRef.current.muted = !(mute)) }}> {mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'} )} @@ -88,6 +94,12 @@ export default () => {

+ {() => { + const { pathname } = useLocation () + useEffect (() => { + scrollTo (0, 0) + }, [pathname]) + }} } /> } /> } /> diff --git a/frontend/src/components/threads/ThreadCanvas.tsx b/frontend/src/components/threads/ThreadCanvas.tsx new file mode 100644 index 0000000..69219c6 --- /dev/null +++ b/frontend/src/components/threads/ThreadCanvas.tsx @@ -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 (null) + const stageRef = useRef (null) + + const [activeLayerId, setActiveLayerId] = useState (null) + const [colour, setColour] = useState ('#000000') + const [images, setImages] = useState> ({ }) + const [layers, setLayers] = useState ([]) + const [layerCnt, setLayerCnt] = useState (0) + const [mode, setMode] = useState (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) => { + 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) => { + 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 = 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 ( +
+
+
+ + + + + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + setColour (ev.target.value)} /> +
+ +
+ + setPointSize (+ev.target.value)} /> + {pointSize} +
+ +
+ + {layers.map (layer => ( + ))} + + + + +
+
+ +
+
+ { + stageRef.current = node + return node + }} + width={stageWidth} + height={stageHeight} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onTouchStart={handleMouseDown} + onTouchMove={handleMouseMove} + onTouchEnd={handleMouseUp}> + + + + + {layers.map (layer => ( + + {images[layer.id] && ( + )} + + {layer.lines.map ((line, i) => ( + ))} + ))} + +
+
+
) +} diff --git a/frontend/src/pages/threads/ThreadDetailPage.tsx b/frontend/src/pages/threads/ThreadDetailPage.tsx index 1033cf8..71b7231 100644 --- a/frontend/src/pages/threads/ThreadDetailPage.tsx +++ b/frontend/src/pages/threads/ThreadDetailPage.tsx @@ -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 ([]) + const [thread, setThread] = useState (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 ( <> + {loading ? 'Loading...' : ( posts.length > 0 ? posts.map (post => ( -
@@ -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}
-
+
)) diff --git a/frontend/src/pages/threads/ThreadListPage.tsx b/frontend/src/pages/threads/ThreadListPage.tsx index dd81687..18b360d 100644 --- a/frontend/src/pages/threads/ThreadListPage.tsx +++ b/frontend/src/pages/threads/ThreadListPage.tsx @@ -41,6 +41,7 @@ export default () => { } useEffect (() => { + document.title = 'キケッツチャンネル お絵描き掲示板' fetchThreads () }, []) @@ -66,7 +67,8 @@ export default () => { {loading ? 'Loading...' : ( threads.length > 0 ? threads.map (thread => ( -
@@ -76,7 +78,7 @@ export default () => { {thread.description?.replaceAll ('\r\n', '\n') .replaceAll ('\r', '\n') .split ('\n') - .map (l =>

{l}

)} + .map ((l, i) =>

{l}

)}