みてるぞ 1 week ago
parent
commit
f806622beb
15 changed files with 246 additions and 110 deletions
  1. +1
    -0
      backend/Gemfile
  2. +4
    -0
      backend/Gemfile.lock
  3. +10
    -1
      backend/app/controllers/posts_controller.rb
  4. +8
    -1
      backend/app/controllers/thread_posts_controller.rb
  5. +6
    -4
      backend/app/controllers/threads_controller.rb
  6. +4
    -0
      backend/app/models/topic.rb
  7. +9
    -9
      backend/config/initializers/cors.rb
  8. +0
    -42
      frontend/src/App.css
  9. +38
    -18
      frontend/src/App.tsx
  10. +22
    -6
      frontend/src/components/threads/ThreadNewForm.tsx
  11. +1
    -1
      frontend/src/config.ts
  12. +0
    -2
      frontend/src/index.css
  13. +104
    -0
      frontend/src/pages/threads/ThreadDetailPage.tsx
  14. +38
    -25
      frontend/src/pages/threads/ThreadListPage.tsx
  15. +1
    -1
      frontend/vite.config.ts

+ 1
- 0
backend/Gemfile View File

@@ -48,3 +48,4 @@ group :development, :test do
end end


gem 'bcrypt', '~> 3.1' gem 'bcrypt', '~> 3.1'
gem 'rack-cors'

+ 4
- 0
backend/Gemfile.lock View File

@@ -187,6 +187,9 @@ GEM
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.0) rack (3.2.0)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -325,6 +328,7 @@ DEPENDENCIES
kamal kamal
mysql2 (~> 0.5) mysql2 (~> 0.5)
puma (>= 5.0) puma (>= 5.0)
rack-cors
rails (~> 8.0.2) rails (~> 8.0.2)
rubocop-rails-omakase rubocop-rails-omakase
solid_cable solid_cable


+ 10
- 1
backend/app/controllers/posts_controller.rb View File

@@ -1,5 +1,14 @@
class PostsController < ApplicationController class PostsController < ApplicationController
before_action :set_post, only: [:good, :bad, :destroy]
before_action :set_post, only: [:show, :good, :bad, :destroy]

def show
render json: @post.as_json.merge(image_url: (
if @post.image.attached?
Rails.application.routes.url_helpers.rails_blob_url(@post.image, only_path: true)
else
nil
end))
end


# POST /posts/:id/good # POST /posts/:id/good
def good def good


+ 8
- 1
backend/app/controllers/thread_posts_controller.rb View File

@@ -10,7 +10,14 @@ class ThreadPostsController < ApplicationController
.select('posts.*, (good - bad) AS score') .select('posts.*, (good - bad) AS score')
.order("#{ sort } #{ order }") .order("#{ sort } #{ order }")


render json: posts
render json: posts.map { |post|
post.as_json.merge(image_url: (
if post.image.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.image, only_path: true)
else
nil
end))
}
end end


# POST /api/threads/:thread_id/posts # POST /api/threads/:thread_id/posts


+ 6
- 4
backend/app/controllers/threads_controller.rb View File

@@ -1,8 +1,10 @@
class ThreadsController < ApplicationController class ThreadsController < ApplicationController
# GET /api/threads # GET /api/threads
def index def index
threads = Topic.order(updated_at: :desc)
render json: threads
threads = Topic.includes(:posts).order(updated_at: :desc)
render json: threads.map { |t|
t.as_json(methods: [:post_count])
}
end end


# GET /api/threads/:id # GET /api/threads/:id
@@ -15,7 +17,7 @@ class ThreadsController < ApplicationController
def create def create
thread = Topic.new(thread_params) thread = Topic.new(thread_params)
if thread.save if thread.save
render json: thread, status: :created
render json: thread.as_json, status: :created
else else
render json: { errors: thread.errors.full_messages }, status: :unprocessable_entity render json: { errors: thread.errors.full_messages }, status: :unprocessable_entity
end end
@@ -24,6 +26,6 @@ class ThreadsController < ApplicationController
private private


def thread_params def thread_params
params.require(:thread).permit(:title, :description)
params.require(:thread).permit(:name, :description)
end end
end end

+ 4
- 0
backend/app/models/topic.rb View File

@@ -6,4 +6,8 @@ class Topic < ApplicationRecord
scope :active, -> { where deleted_at: nil } scope :active, -> { where deleted_at: nil }


validates :name, presence: true validates :name, presence: true

def post_count
posts.count
end
end end

+ 9
- 9
backend/config/initializers/cors.rb View File

@@ -5,12 +5,12 @@


# Read more: https://github.com/cyu/rack-cors # Read more: https://github.com/cyu/rack-cors


# Rails.application.config.middleware.insert_before 0, Rack::Cors do
# allow do
# origins "example.com"
#
# resource "*",
# headers: :any,
# methods: [:get, :post, :put, :patch, :delete, :options, :head]
# end
# end
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://bbs.kekec.wiki:5173', 'https://bbs.kekec.wiki'
resource "*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end

+ 0
- 42
frontend/src/App.css View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}

.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}

.card {
padding: 2em;
}

.read-the-docs {
color: #888;
}

+ 38
- 18
frontend/src/App.tsx View File

@@ -4,7 +4,7 @@ import { BrowserRouter, Link, Routes, Route, Navigate } 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'
// import ThreadDetailPage from '@/pages/threads/ThreadDetailPage'
import ThreadDetailPage from '@/pages/threads/ThreadDetailPage'


const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900', const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900',
'bg-lime-500 dark:bg-lime-900', 'bg-lime-500 dark:bg-lime-900',
@@ -18,11 +18,12 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900',




export default () => { export default () => {
const [bgm] = useState (new Audio (bgmSrc))
const [colourIndex, setColourIndex] = useState (0) const [colourIndex, setColourIndex] = useState (0)
const [mute, setMute] = useState (false)
const [playing, setPlaying] = useState (false) const [playing, setPlaying] = useState (false)


useEffect (() => { useEffect (() => {
const bgm = new Audio (bgmSrc)
bgm.loop = true bgm.loop = true


const playBGM = async () => { const playBGM = async () => {
@@ -60,24 +61,43 @@ export default () => {


return ( return (
<BrowserRouter> <BrowserRouter>
<div className={cn ('w-screen 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')}>
<h1 className="text-center py-7">
<Link to="/"
className="text-7xl text-transparent whitespace-nowrap
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
bg-clip-text [transform:skewX(-13.5deg)]
inline-block bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
font-serif hover:text-transparent dark:hover:text-transparent">
クソ掲示板
</Link>
</h1>
<Routes>
<Route path="/" element={<Navigate to="/threads" replace />} />
<Route path="/threads" element={<ThreadListPage />} />
{/* <Route path="/threads/:id" element={<ThreadDetailPage />} /> */}
</Routes>
<div className="mx-auto max-w-[960px]">
<header className="pt-6 mb-8">
<h1 className="text-center">
<Link to="/"
className="text-7xl text-transparent whitespace-nowrap
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
bg-clip-text [transform:skewX(-13.5deg)]
inline-block bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
font-serif hover:text-transparent dark:hover:text-transparent">
クソ掲示板
</Link>
</h1>
<div className="text-center my-6">
{playing && (
<a href="#" onClick={ev => {
ev.preventDefault ()
setMute (bgm.muted = !(mute))
}}>
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
</a>)}
</div>
</header>
<main className="mb-8">
<Routes>
<Route path="/" element={<Navigate to="/threads" replace />} />
<Route path="/threads" element={<ThreadListPage />} />
<Route path="/threads/:id" element={<ThreadDetailPage />} />
</Routes>
</main>
<hr />
<footer className="text-center mt-8 pb-12 text-base text-gray-500 dark:text-gray-300">
© このペィジへの投稿は,すべて,パブリック・ドメインとします.
</footer>
</div>
</div> </div>
</BrowserRouter>) </BrowserRouter>)
} }

+ 22
- 6
frontend/src/components/threads/ThreadNewForm.tsx View File

@@ -1,26 +1,41 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'


import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'


import type { Thread } from '@/types'



export default () => { export default () => {
const [threadName, setThreadName] = useState ('')
const navigate = useNavigate ()

const [disabled, setDisabled] = useState (false)
const [threadDescription, setThreadDescription] = useState ('') const [threadDescription, setThreadDescription] = useState ('')
const [threadName, setThreadName] = useState ('')


const submit = async () => { const submit = async () => {
const formData = new FormData
formData.append ('title', threadName)
formData.append ('description', threadDescription)
if (!(threadName))
{
alert ('スレ名入れろよ.')
return
}


try try
{ {
await axios.post (`${ API_BASE_URL }/threads`, formData)
setDisabled (true)
const res = await axios.post (`${ API_BASE_URL }/threads`, { thread:
{ name: threadName, description: threadDescription } })
const { id } = toCamel (res.data as any, { deep: true }) as Thread
navigate (`/threads/${ id }`)
} }
catch catch
{ {
;
setDisabled (false)
} }
setThreadName ('')
setThreadDescription ('')
} }


return ( return (
@@ -44,6 +59,7 @@ export default () => {


{/* 作成 */} {/* 作成 */}
<button type="button" <button type="button"
disabled={disabled}
onClick={submit}> onClick={submit}>
スレッド作成 スレッド作成
</button> </button>


+ 1
- 1
frontend/src/config.ts View File

@@ -1,2 +1,2 @@
export const API_BASE_URL = 'http://localhost:3003'
export const API_BASE_URL = 'https://api.bbs.kekec.wiki'
export const SITE_NAME = 'キケッツチャンネル お絵描き掲示板' export const SITE_NAME = 'キケッツチャンネル お絵描き掲示板'

+ 0
- 2
frontend/src/index.css View File

@@ -26,8 +26,6 @@
font-weight: 400; font-weight: 400;


color-scheme: light dark; color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;


font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;


+ 104
- 0
frontend/src/pages/threads/ThreadDetailPage.tsx View File

@@ -0,0 +1,104 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa'
import { useParams } from 'react-router-dom'

import { API_BASE_URL } from '@/config'


export default () => {
const { id } = useParams ()

const [loading, setLoading] = useState (true)
const [posts, setPosts] = useState<Post[]> ([])

useEffect (() => {
const fetchPosts = async () => {
setLoading (true)
try
{
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`)
const data = toCamel (res.data as any, { deep: true }) as Post[]
setPosts (data.map (p => ({
...p,
createdAt: (new Date (p.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') })))
}
catch
{
;
}
setLoading (false)
}

setPosts ([])
fetchPosts ()
}, [])

return (
<>
{loading ? 'Loading...' : (
posts.length > 0
? posts.map (post => (
<div 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">
<span>{post.postNo}: {post.name || '名なしさん'}</span>
<span>{post.createdAt}</span>
</div>
<div className="flex items-center px-4 pt-1 pb-3">
<a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap"
href="#"
onClick={async ev => {
ev.preventDefault ()
try
{
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
setPosts (prev => prev.map (p => (p.id == post.id
? { ...p, bad: p.bad + 1 }
: p)))
}
catch
{
;
}
}}>
<FaThumbsDown className="inline" /> {post.bad}
</a>
<div className="h-2 bg-blue-600 dark:bg-blue-300"
style={{ width: `${ post.good + post.bad === 0
? 50
: post.bad * 100 / (post.good + post.bad) }%` }}>
</div>
<div className="h-2 bg-red-600 dark:bg-red-300"
style={{ width: `${ post.good + post.bad === 0
? 50
: post.good * 100 / (post.good + post.bad) }%` }}>
</div>
<a className="text-red-600 dark:text-red-300 ml-1 whitespace-nowrap"
href="#"
onClick={async ev => {
ev.preventDefault ()
try
{
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`)
setPosts (prev => prev.map (p => (p.id == post.id
? { ...p, good: p.good + 1 }
: p)))
}
catch
{
;
}
}}>
{post.good} <FaThumbsUp className="inline" />
</a>
</div>
<div className="bg-white inline-block">
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
</div>
</div>))
: 'レスないよ(笑).')}
</>)
}

+ 38
- 25
frontend/src/pages/threads/ThreadListPage.tsx View File

@@ -8,40 +8,47 @@ import { Accordion,
AccordionItemHeading, AccordionItemHeading,
AccordionItemPanel } from 'react-accessible-accordion' AccordionItemPanel } from 'react-accessible-accordion'
import { FaChevronDown, FaChevronUp } from 'react-icons/fa' import { FaChevronDown, FaChevronUp } from 'react-icons/fa'
import { Link } from 'react-router-dom'


import ThreadNewForm from '@/components/threads/ThreadNewForm' import ThreadNewForm from '@/components/threads/ThreadNewForm'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'


import type { Thread } from '@/types'



export default () => { export default () => {
const [formOpen, setFormOpen] = useState (false)
const [loading, setLoading] = useState (true) const [loading, setLoading] = useState (true)
const [threads, setThreads] = useState<Thread[]> ([]) const [threads, setThreads] = useState<Thread[]> ([])
const [formOpen, setFormOpen] = useState (false)


useEffect (() => {
const fetchThreads = async () => {
try
{
const res = await axios.get (`${ API_BASE_URL }/threads`)
const data = toCamel (res.data as any, { deep: true }) as { threads: Thread[] }
setThreads (data.threads)
}
catch
{
;
}
const fetchThreads = async () => {
setLoading (true)
try
{
const res = await axios.get (`${ API_BASE_URL }/threads`)
const data = toCamel (res.data as any, { deep: true }) as Thread[]
const threads = data.filter (t => t.id !== 2)
setThreads (threads.map (t => ({
...t,
createdAt: (new Date (t.createdAt)).toLocaleString ('ja-JP-u-ca-japanese'),
updatedAt: (new Date (t.updatedAt)).toLocaleString ('ja-JP-u-ca-japanese') })))
} }
catch
{
;
}
setLoading (false)
}


setLoading (true)
useEffect (() => {
fetchThreads () fetchThreads ()
setLoading (false)
}, []) }, [])


return ( return (
<> <>
<Accordion allowZeroExpanded <Accordion allowZeroExpanded
onClick={() => setFormOpen (!formOpen)} onClick={() => setFormOpen (!formOpen)}
className="mb-4">
className="mb-4 mx-auto">
<AccordionItem> <AccordionItem>
<AccordionItemHeading> <AccordionItemHeading>
<AccordionItemButton className="flex items-center"> <AccordionItemButton className="flex items-center">
@@ -57,20 +64,26 @@ export default () => {


<div> <div>
{loading ? 'Loading...' : ( {loading ? 'Loading...' : (
threads.length
threads.length > 0
? threads.map (thread => ( ? threads.map (thread => (
<div className="bg-white p-2 mb-2 border border-gray-400
rounded-xl">
<div className="bg-white dark:bg-gray-800 p-3 m-4
border border-gray-400 rounded-xl">
<div> <div>
<Link to={`/threads/${ thread.id }`}> <Link to={`/threads/${ thread.id }`}>
<h3>{thread.title}</h3>
<h3>{thread.name}</h3>
</Link> </Link>
<p>{thread.description}</p>
<div className="my-2">
{thread.description?.replaceAll ('\r\n', '\n')
.replaceAll ('\r', '\n')
.split ('\n')
.map (l => <p>{l}</p>)}
</div>
</div> </div>
<div className="flex justify-between text-sm text-gray-600">
<span>{thread.postCount} レス</span>
<span>{thread.updatedAt} 更新</span>
<span>{thread.createdAt} 作成</span>
<div className="grid grid-cols-3 justify-between text-sm
text-gray-600 dark:text-gray-300">
<span className="text-left">{thread.postCount} レス</span>
<span className="text-center">{thread.updatedAt} 更新</span>
<span className="text-right">{thread.createdAt} 作成</span>
</div> </div>
</div>)) </div>))
: 'スレないよ(笑).')} : 'スレないよ(笑).')}


+ 1
- 1
frontend/vite.config.ts View File

@@ -6,5 +6,5 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true },
server: { host: true, allowedHosts: ['miteruzo.com', 'bbs.kekec.wiki'] },
}) })

Loading…
Cancel
Save