From f806622beb8683163ee6504fc30f5224ccea4449 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 7 Aug 2025 02:05:08 +0900 Subject: [PATCH] #3 --- backend/Gemfile | 1 + backend/Gemfile.lock | 4 + backend/app/controllers/posts_controller.rb | 11 +- .../controllers/thread_posts_controller.rb | 9 +- backend/app/controllers/threads_controller.rb | 10 +- backend/app/models/topic.rb | 4 + backend/config/initializers/cors.rb | 18 +-- frontend/src/App.css | 42 ------- frontend/src/App.tsx | 56 +++++++--- .../src/components/threads/ThreadNewForm.tsx | 28 ++++- frontend/src/config.ts | 2 +- frontend/src/index.css | 2 - .../src/pages/threads/ThreadDetailPage.tsx | 104 ++++++++++++++++++ frontend/src/pages/threads/ThreadListPage.tsx | 63 ++++++----- frontend/vite.config.ts | 2 +- 15 files changed, 246 insertions(+), 110 deletions(-) delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/pages/threads/ThreadDetailPage.tsx diff --git a/backend/Gemfile b/backend/Gemfile index a5a869f..8849486 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -48,3 +48,4 @@ group :development, :test do end gem 'bcrypt', '~> 3.1' +gem 'rack-cors' diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 28a84c9..54e39cc 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -187,6 +187,9 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.0) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -325,6 +328,7 @@ DEPENDENCIES kamal mysql2 (~> 0.5) puma (>= 5.0) + rack-cors rails (~> 8.0.2) rubocop-rails-omakase solid_cable diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 8a5b171..9c8948e 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -1,5 +1,14 @@ 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 def good diff --git a/backend/app/controllers/thread_posts_controller.rb b/backend/app/controllers/thread_posts_controller.rb index fb795b8..b87b7b5 100644 --- a/backend/app/controllers/thread_posts_controller.rb +++ b/backend/app/controllers/thread_posts_controller.rb @@ -10,7 +10,14 @@ class ThreadPostsController < ApplicationController .select('posts.*, (good - bad) AS score') .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 # POST /api/threads/:thread_id/posts diff --git a/backend/app/controllers/threads_controller.rb b/backend/app/controllers/threads_controller.rb index 816452a..57057dc 100644 --- a/backend/app/controllers/threads_controller.rb +++ b/backend/app/controllers/threads_controller.rb @@ -1,8 +1,10 @@ class ThreadsController < ApplicationController # GET /api/threads 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 # GET /api/threads/:id @@ -15,7 +17,7 @@ class ThreadsController < ApplicationController def create thread = Topic.new(thread_params) if thread.save - render json: thread, status: :created + render json: thread.as_json, status: :created else render json: { errors: thread.errors.full_messages }, status: :unprocessable_entity end @@ -24,6 +26,6 @@ class ThreadsController < ApplicationController private def thread_params - params.require(:thread).permit(:title, :description) + params.require(:thread).permit(:name, :description) end end diff --git a/backend/app/models/topic.rb b/backend/app/models/topic.rb index 96d0d83..05f31ef 100644 --- a/backend/app/models/topic.rb +++ b/backend/app/models/topic.rb @@ -6,4 +6,8 @@ class Topic < ApplicationRecord scope :active, -> { where deleted_at: nil } validates :name, presence: true + + def post_count + posts.count + end end diff --git a/backend/config/initializers/cors.rb b/backend/config/initializers/cors.rb index 0c5dd99..c43e8e0 100644 --- a/backend/config/initializers/cors.rb +++ b/backend/config/initializers/cors.rb @@ -5,12 +5,12 @@ # 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 diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12a25c0..c17058c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { BrowserRouter, Link, Routes, Route, Navigate } from 'react-router-dom' import bgmSrc from '@/assets/music.mp3' 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', 'bg-lime-500 dark:bg-lime-900', @@ -18,11 +18,12 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900', export default () => { + const [bgm] = useState (new Audio (bgmSrc)) const [colourIndex, setColourIndex] = useState (0) + const [mute, setMute] = useState (false) const [playing, setPlaying] = useState (false) useEffect (() => { - const bgm = new Audio (bgmSrc) bgm.loop = true const playBGM = async () => { @@ -60,24 +61,43 @@ export default () => { return ( -
-

- - クソ掲示板 - -

- - } /> - } /> - {/* } /> */} - +
+
+

+ + クソ掲示板 + +

+ +
+
+ + } /> + } /> + } /> + +
+
+
+ © このペィジへの投稿は,すべて,パブリック・ドメインとします. +
+
) } diff --git a/frontend/src/components/threads/ThreadNewForm.tsx b/frontend/src/components/threads/ThreadNewForm.tsx index 2f7943e..14dec26 100644 --- a/frontend/src/components/threads/ThreadNewForm.tsx +++ b/frontend/src/components/threads/ThreadNewForm.tsx @@ -1,26 +1,41 @@ import axios from 'axios' +import toCamel from 'camelcase-keys' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { API_BASE_URL } from '@/config' +import type { Thread } from '@/types' + export default () => { - const [threadName, setThreadName] = useState ('') + const navigate = useNavigate () + + const [disabled, setDisabled] = useState (false) const [threadDescription, setThreadDescription] = useState ('') + const [threadName, setThreadName] = useState ('') const submit = async () => { - const formData = new FormData - formData.append ('title', threadName) - formData.append ('description', threadDescription) + if (!(threadName)) + { + alert ('スレ名入れろよ.') + return + } 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 { - ; + setDisabled (false) } + setThreadName ('') + setThreadDescription ('') } return ( @@ -44,6 +59,7 @@ export default () => { {/* 作成 */} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5d31c26..5a261b4 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -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 = 'キケッツチャンネル お絵描き掲示板' diff --git a/frontend/src/index.css b/frontend/src/index.css index adc5b01..dcb29ff 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,8 +26,6 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; diff --git a/frontend/src/pages/threads/ThreadDetailPage.tsx b/frontend/src/pages/threads/ThreadDetailPage.tsx new file mode 100644 index 0000000..1033cf8 --- /dev/null +++ b/frontend/src/pages/threads/ThreadDetailPage.tsx @@ -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 ([]) + + 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 => ( +
+
+ {post.postNo}: {post.name || '名なしさん'} + {post.createdAt} +
+
+ { + 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 + { + ; + } + }}> + {post.bad} + +
+
+
+
+ { + 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} + +
+
+ +
+
)) + : 'レスないよ(笑).')} + ) +} diff --git a/frontend/src/pages/threads/ThreadListPage.tsx b/frontend/src/pages/threads/ThreadListPage.tsx index 44a287a..dd81687 100644 --- a/frontend/src/pages/threads/ThreadListPage.tsx +++ b/frontend/src/pages/threads/ThreadListPage.tsx @@ -8,40 +8,47 @@ import { Accordion, AccordionItemHeading, AccordionItemPanel } from 'react-accessible-accordion' import { FaChevronDown, FaChevronUp } from 'react-icons/fa' +import { Link } from 'react-router-dom' import ThreadNewForm from '@/components/threads/ThreadNewForm' import { API_BASE_URL } from '@/config' +import type { Thread } from '@/types' + export default () => { + const [formOpen, setFormOpen] = useState (false) const [loading, setLoading] = useState (true) const [threads, setThreads] = useState ([]) - 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 () - setLoading (false) }, []) return ( <> setFormOpen (!formOpen)} - className="mb-4"> + className="mb-4 mx-auto"> @@ -57,20 +64,26 @@ export default () => {
{loading ? 'Loading...' : ( - threads.length + threads.length > 0 ? threads.map (thread => ( -
+
-

{thread.title}

+

{thread.name}

-

{thread.description}

+
+ {thread.description?.replaceAll ('\r\n', '\n') + .replaceAll ('\r', '\n') + .split ('\n') + .map (l =>

{l}

)} +
-
- {thread.postCount} レス - {thread.updatedAt} 更新 - {thread.createdAt} 作成 +
+ {thread.postCount} レス + {thread.updatedAt} 更新 + {thread.createdAt} 作成
)) : 'スレないよ(笑).')} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4a72a9b..8014ebe 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,5 +6,5 @@ import path from 'path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve (__dirname, './src') } }, - server: { host: true }, + server: { host: true, allowedHosts: ['miteruzo.com', 'bbs.kekec.wiki'] }, })