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