| @@ -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": { | |||
| @@ -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 | |||