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