みてるぞ 4 days ago
parent
commit
a18ce64d7a
6 changed files with 540 additions and 17 deletions
  1. +98
    -0
      frontend/package-lock.json
  2. +2
    -0
      frontend/package.json
  3. +21
    -9
      frontend/src/App.tsx
  4. +387
    -0
      frontend/src/components/threads/ThreadCanvas.tsx
  5. +28
    -6
      frontend/src/pages/threads/ThreadDetailPage.tsx
  6. +4
    -2
      frontend/src/pages/threads/ThreadListPage.tsx

+ 98
- 0
frontend/package-lock.json View File

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


+ 2
- 0
frontend/package.json View File

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


+ 21
- 9
frontend/src/App.tsx View File

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


+ 387
- 0
frontend/src/components/threads/ThreadCanvas.tsx View File

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

+ 28
- 6
frontend/src/pages/threads/ThreadDetailPage.tsx View File

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


+ 4
- 2
frontend/src/pages/threads/ThreadListPage.tsx View File

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


Loading…
Cancel
Save