| @@ -48,3 +48,4 @@ group :development, :test do | |||||
| end | end | ||||
| gem 'bcrypt', '~> 3.1' | gem 'bcrypt', '~> 3.1' | ||||
| gem 'rack-cors' | |||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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>) | ||||
| } | } | ||||
| @@ -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,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 = 'キケッツチャンネル お絵描き掲示板' | ||||
| @@ -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; | ||||
| @@ -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>)) | |||||
| : 'レスないよ(笑).')} | |||||
| </>) | |||||
| } | |||||
| @@ -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>)) | ||||
| : 'スレないよ(笑).')} | : 'スレないよ(笑).')} | ||||
| @@ -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'] }, | |||||
| }) | }) | ||||