@@ -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'] }, | |||||
}) | }) |