| @@ -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 | ||||