| @@ -48,3 +48,4 @@ group :development, :test do | |||
| end | |||
| gem 'bcrypt', '~> 3.1' | |||
| gem 'rack-cors' | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -6,4 +6,8 @@ class Topic < ApplicationRecord | |||
| scope :active, -> { where deleted_at: nil } | |||
| validates :name, presence: true | |||
| def post_count | |||
| posts.count | |||
| end | |||
| end | |||
| @@ -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 | |||
| @@ -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 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 ( | |||
| <BrowserRouter> | |||
| <div className={cn ('w-screen h-screen', | |||
| <div className={cn ('w-screen min-h-screen', | |||
| colours[colourIndex], | |||
| '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> | |||
| </BrowserRouter>) | |||
| } | |||
| @@ -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 () => { | |||
| {/* 作成 */} | |||
| <button type="button" | |||
| disabled={disabled} | |||
| onClick={submit}> | |||
| スレッド作成 | |||
| </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 = 'キケッツチャンネル お絵描き掲示板' | |||
| @@ -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; | |||
| @@ -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, | |||
| 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<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 () | |||
| setLoading (false) | |||
| }, []) | |||
| return ( | |||
| <> | |||
| <Accordion allowZeroExpanded | |||
| onClick={() => setFormOpen (!formOpen)} | |||
| className="mb-4"> | |||
| className="mb-4 mx-auto"> | |||
| <AccordionItem> | |||
| <AccordionItemHeading> | |||
| <AccordionItemButton className="flex items-center"> | |||
| @@ -57,20 +64,26 @@ export default () => { | |||
| <div> | |||
| {loading ? 'Loading...' : ( | |||
| threads.length | |||
| threads.length > 0 | |||
| ? 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> | |||
| <Link to={`/threads/${ thread.id }`}> | |||
| <h3>{thread.title}</h3> | |||
| <h3>{thread.name}</h3> | |||
| </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 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>)) | |||
| : 'スレないよ(笑).')} | |||
| @@ -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'] }, | |||
| }) | |||