This commit is contained in:
2025-05-25 14:53:37 +09:00
parent fa547bc62f
commit 41668fa894
8 changed files with 209 additions and 141 deletions
+2 -2
View File
@@ -18,13 +18,13 @@ class PostsController < ApplicationController
else else
@posts = Post.all @posts = Post.all
end end
render json: @posts render json: @posts.as_json(include: { tags: { only: [:id, :name, :category] } })
end end
# GET /posts/1 # GET /posts/1
def show def show
@post = Post.includes(:tags).find(params[:id]) @post = Post.includes(:tags).find(params[:id])
render json: @post.as_json(include: { tags: { only: [:id, :name] } }) render json: @post.as_json(include: { tags: { only: [:id, :name, :category] } })
end end
# POST /posts # POST /posts
+23 -10
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import TagPage from './pages/TagPage' import TagPage from './pages/TagPage'
@@ -7,28 +7,41 @@ import TagSidebar from './components/TagSidebar'
import PostPage from './pages/PostPage' import PostPage from './pages/PostPage'
import PostDetailPage from './pages/PostDetailPage' import PostDetailPage from './pages/PostDetailPage'
type Tag = { id: number
name: string
category: string }
type Post = { id: number
url: string
title: string
thumbnail: string
tags: Tag[] }
const App = () => { const App = () => {
const [posts, setPosts] = useState<Post[]> ([])
useEffect (() => {
alert ('このサイトはまだ作りかけです!!!!\n出てけ!!!!!!!!!!!!!!!!!!!!')
}, [])
return ( return (
<Router> <Router>
<div className="flex flex-col h-screen w-screen"> <div className="flex flex-col h-screen w-screen">
<TopNav /> <TopNav />
<div className="flex flex-1"> <div className="flex flex-1">
<Routes> <TagSidebar posts={posts} setPosts={setPosts} />
<Route path="/posts/:postId" element={<TagSidebar />} />
<Route path="*" element={<TagSidebar />} />
</Routes>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
<Routes> <Routes>
<Route path="/" element={<PostPage />} /> <Route path="/posts/:id" element={<PostDetailPage posts={posts} setPosts={setPosts} />} />
<Route path="/posts" element={<PostPage />} />
<Route path="/posts/:id" element={<PostDetailPage />} />
<Route path="/tags/:tag" element={<TagPage />} /> <Route path="/tags/:tag" element={<TagPage />} />
<Route path="*" element={<PostPage posts={posts} setPosts={setPosts} />} />
</Routes> </Routes>
</main> </main>
</div> </div>
</div> </div>
</Router> </Router>)
)
} }
export default App export default App
+30 -27
View File
@@ -15,23 +15,23 @@ const NicoViewer: React.FC = (props: Props) => {
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
const [isLandscape, setIsLandScape] = useState<boolean> (false) const [landscape, setLandscape] = useState<boolean> (false)
const [isFullScreen, setIsFullScreen] = useState<boolean> (false) const [fullScreen, setFullScreen] = useState<boolean> (false)
const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`; const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`;
const styleFullScreen: CSSProperties = isFullScreen ? { const styleFullScreen: CSSProperties = fullScreen ? {
top: 0, top: 0,
left: isLandscape ? 0 : '100%', left: landscape ? 0 : '100%',
position: 'fixed', position: 'fixed',
width: screenWidth, width: screenWidth,
height: screenHeight, height: screenHeight,
zIndex: 2147483647, zIndex: 2147483647,
maxWidth: 'none', maxWidth: 'none',
transformOrigin: '0% 0%', transformOrigin: '0% 0%',
transform: isLandscape ? 'none' : 'rotate(90deg)', transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%', WebkitTransformOrigin: '0% 0%',
WebkitTransform: isLandscape ? 'none' : 'rotate(90deg)', WebkitTransform: landscape ? 'none' : 'rotate(90deg)',
} : {}; } : {};
const margedStyle = { const margedStyle = {
@@ -45,38 +45,40 @@ const NicoViewer: React.FC = (props: Props) => {
const onMessage = (event: MessageEvent<any>) => { const onMessage = (event: MessageEvent<any>) => {
if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return; if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return;
if (event.data.eventName === 'enterProgrammaticFullScreen') { if (event.data.eventName === 'enterProgrammaticFullScreen') {
setIsFullScreen(true); setFullScreen(true);
} else if (event.data.eventName === 'exitProgrammaticFullScreen') { } else if (event.data.eventName === 'exitProgrammaticFullScreen') {
setIsFullScreen(false); setFullScreen(false);
} }
}; };
window.addEventListener('message', onMessage); addEventListener('message', onMessage);
return () => { return () => {
window.removeEventListener('message', onMessage); removeEventListener('message', onMessage);
}; };
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isFullScreen) return; if (!(fullScreen))
return
const initialScrollX = window.scrollX; const initialScrollX = window.scrollX
const initialScrollY = window.scrollY; const initialScrollY = window.scrollY
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout
let ended = false; let ended = false
const pollingResize = () => { const pollingResize = () => {
if (ended) return; if (ended)
return
const isLandscape = window.innerWidth >= window.innerHeight; const landscape = window.innerWidth >= window.innerHeight
const windowWidth = `${isLandscape ? window.innerWidth : window.innerHeight}px`; const windowWidth = `${landscape ? window.innerWidth : window.innerHeight}px`
const windowHeight = `${isLandscape ? window.innerHeight : window.innerWidth}px`; const windowHeight = `${landscape ? window.innerHeight : window.innerWidth}px`
setIsLandScape(isLandscape); setLandScape (Landscape)
setScreenWidth(windowWidth); setScreenWidth (windowWidth)
setScreenHeight(windowHeight); setScreenHeight (windowHeight)
timer = setTimeout(startPollingResize, 200); timer = setTimeout (startPollingResize, 200)
} }
const startPollingResize = () => { const startPollingResize = () => {
@@ -94,12 +96,13 @@ const NicoViewer: React.FC = (props: Props) => {
ended = true; ended = true;
window.scrollTo(initialScrollX, initialScrollY); window.scrollTo(initialScrollX, initialScrollY);
}; };
}, [isFullScreen]); }, [fullScreen]);
useEffect(() => { useEffect(() => {
if (!isFullScreen) return; if (!(fullScreen))
window.scrollTo(0, 0); return
}, [screenWidth, screenHeight, isFullScreen]); scrollTo (0, 0)
}, [screenWidth, screenHeight, fullScreen])
return <iframe ref={iframeRef} return <iframe ref={iframeRef}
src={src} src={src}
+7 -3
View File
@@ -7,10 +7,8 @@ import { API_BASE_URL } from '../config'
const TagSearch: React.FC = () => { const TagSearch: React.FC = () => {
const navigate = useNavigate () const navigate = useNavigate ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const [search, setSearch] = useState (tagsQuery) const [search, setSearch] = useState ('')
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && search.length > 0) if (e.key === 'Enter' && search.length > 0)
@@ -18,6 +16,12 @@ const TagSearch: React.FC = () => {
{ replace: true }) { replace: true })
} }
useEffect (() => {
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
setSearch (tagsQuery)
}, [location.search])
return ( return (
<input <input
type="text" type="text"
+48 -27
View File
@@ -5,64 +5,85 @@ import { API_BASE_URL } from '../config'
import TagSearch from './TagSearch' import TagSearch from './TagSearch'
type Tag = { id: number type Tag = { id: number
name: string } name: string
category: string }
type TagByCategory = { [key: string]: Tag[] } type TagByCategory = { [key: string]: Tag[] }
type OriginalTag = { id: number type OriginalTag = { id: number
name: string name: string
category: string } category: string }
type Post = { id: number
url: string
title: string
thumbnail: string
tags: Tag[] }
type Props = { posts: Post[]
setPosts: (posts: Post[]) => void }
const tagNameMap: { [key: string]: string } = { const tagNameMap: { [key: string]: string } = {
general: '一般', general: '一般',
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
nico: 'ニコニコタグ' } nico: 'ニコニコタグ' }
const TagSidebar: React.FC = () => { const TagSidebar: React.FC = (props: Props) => {
const { postId } = useParams<{ postId?: number }> () const { posts, setPosts } = props
const [tags, setTags] = useState<TagByCategory> ({ })
useEffect(() => { const [tags, setTags] = useState<TagByCategory> ({ })
const [tagsCounts, setTagsCounts] = useState<{ [key: id]: number }> ({ })
useEffect (() => {
const fetchTags = async () => { const fetchTags = async () => {
try { try
const response = await axios.get (`${API_BASE_URL}/tags` {
+ (postId ? `?post=${ postId }` : '')) let tagsTmp: TagByCategory = { }
const tagsTmp: TagByCategory = { } let tagsCountsTmp: { [key: id]: number } = { }
for (const tag of response.data) for (const post of posts)
{
for (const tag of post.tags)
{ {
if (!(tag.category in tagsTmp)) if (!(tag.category in tagsTmp))
tagsTmp[tag.category] = [] tagsTmp[tag.category] = []
tagsTmp[tag.category].push ({ id: tag.id, name: tag.name }) tagsTmp[tag.category].push (tag)
if (!(tag.id in tagsCountsTmp))
tagsCountsTmp[tag.id] = 0
++tagsCountsTmp[tag.id]
} }
}
for (const cat of Object.keys (tagsTmp))
tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1)
setTags (tagsTmp) setTags (tagsTmp)
} catch (error) { setTagsCounts (tagsCountsTmp)
console.error('Failed to fetch tags:', error) }
catch (error)
{
console.error ('Failed to fetch tags:', error)
} }
} }
fetchTags() fetchTags ()
}, [postId]) }, [posts])
return ( return (
<div className="w-64 bg-gray-100 p-4 border-r border-gray-200 h-full"> <div className="w-64 bg-gray-100 p-4 border-r border-gray-200 h-full">
<TagSearch /> <TagSearch />
{['general', 'deerjikist', 'nico'].map (cat => (cat in tags) ? ( {['general', 'deerjikist', 'nico'].map (cat => cat in tags && <>
<>
<h2>{tagNameMap[cat]}</h2> <h2>{tagNameMap[cat]}</h2>
<ul> <ul>
{tags[cat].map (tag => ( {tags[cat].map (tag => (
<li key={tag.id} className="mb-2"> <li key={tag.id} className="mb-2">
<Link <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} className="text-blue-600 hover:underline">
className="text-blue-600 hover:underline"
>
{tag.name} {tag.name}
</Link> </Link>
</li> {posts.length > 1 && <span className="ml-1">{tagsCounts[tag.id]}</span>}
))} </li>))}
</ul> </ul>
</> </>)}
) : <></>)} </div>)
</div>
)
} }
export default TagSidebar export default TagSidebar
+9 -11
View File
@@ -1,20 +1,18 @@
import React from "react" import React from "react"
import { Link } from 'react-router-dom'
const TopNav: React.FC = () => { const TopNav: React.FC = () => (
return (
<nav className="bg-gray-800 text-white p-3 flex justify-between items-center w-full"> <nav className="bg-gray-800 text-white p-3 flex justify-between items-center w-full">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a href="/" className="text-xl font-bold text-orange-500"> </a> <Link to="/" className="text-xl font-bold text-orange-500"> </Link>
<a href="/" className="hover:text-orange-500"></a> <Link to="/posts" className="hover:text-orange-500"></Link>
<a href="/deerjikists" className="hover:text-orange-500"></a> <Link to="/deerjikists" className="hover:text-orange-500"></Link>
<a href="/tags" className="hover:text-orange-500"></a> <Link to="/tags" className="hover:text-orange-500"></Link>
<a href="/wiki" className="hover:text-orange-500">Wiki</a> <Link to="/wiki" className="hover:text-orange-500">Wiki</Link>
</div> </div>
<div className="ml-auto pr-4"> <div className="ml-auto pr-4">
<a href="/login" className="hover:text-orange-500"></a> <Link to="/login" className="hover:text-orange-500"></Link>
</div> </div>
</nav> </nav>)
)
}
export default TopNav export default TopNav
+30 -14
View File
@@ -4,15 +4,23 @@ import axios from 'axios'
import { API_BASE_URL, SITE_TITLE } from '../config' import { API_BASE_URL, SITE_TITLE } from '../config'
import NicoViewer from '../components/NicoViewer' import NicoViewer from '../components/NicoViewer'
type Tag = { id: number
name: string
category: string }
type Post = { id: number type Post = { id: number
url: string url: string
title: string title: string
thumbnail: string thumbnail: string
tags: { name: string }[] } tags: Tag[] }
const PostDetailPage = () => { type Props = { posts: Post[]
setPosts: (posts: Post[]) => void }
const PostDetailPage = (props: Props) => {
const { posts, setPosts } = props
const { id } = useParams () const { id } = useParams ()
const [post, setPost] = useState<Post | null> (null)
const location = useLocation () const location = useLocation ()
@@ -20,28 +28,36 @@ const PostDetailPage = () => {
if (!(id)) if (!(id))
return return
void (axios.get (`/api/posts/${ id }`) void (axios.get (`/api/posts/${ id }`)
.then (res => setPost (res.data)) .then (res => setPosts ([res.data]))
.catch (err => console.error ('うんち!', err))) .catch (err => console.error ('うんち!', err)))
}, [id]) }, [id])
if (!(post)) if (!(posts.length))
return <div>Loading...</div> return <div>Loading...</div>
document.title = `${ post.url } | ${ SITE_TITLE }` const post = posts[0]
document.title = `${ post.title || post.url } | ${ SITE_TITLE }`
const url = new URL (post.url) const url = new URL (post.url)
return ( return (
<div className="p-4"> <div className="p-4">
{url.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp' ? ( {(() => {
<NicoViewer id={url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)[0]} if (url.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp')
{
return (
<NicoViewer
id={url.pathname.match (
/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)[0]}
width="640" width="640"
height="360" /> height="360" />)
) : ( }
<img src={post.thumbnail} alt={post.url} className="mb-4 w-full" /> else
)} return <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />
</div> }) ()}
) </div>)
} }
export default PostDetailPage export default PostDetailPage
+31 -18
View File
@@ -3,8 +3,22 @@ import { Link, useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { API_BASE_URL, SITE_TITLE } from '../config' import { API_BASE_URL, SITE_TITLE } from '../config'
const PostPage = () => { type Tag = { id: number
const [posts, setPosts] = useState([]) name: string
category: string }
type Post = { id: number
url: string
title: string
thumbnail: string
tags: Tag[] }
type Props = { posts: Post[]
setPosts: (posts: Post[]) => void }
const PostPage = (props: Props) => {
const { posts, setPosts } = props
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -19,14 +33,17 @@ const PostPage = () => {
useEffect(() => { useEffect(() => {
const fetchPosts = async () => { const fetchPosts = async () => {
try { try
const response = await axios.get(`${ API_BASE_URL }/posts`, { {
const res = await axios.get (`${ API_BASE_URL }/posts`, {
params: { tags: tags.join (','), params: { tags: tags.join (','),
match: (anyFlg ? 'any' : 'all') } }) match: (anyFlg ? 'any' : 'all') } })
setPosts(response.data) setPosts (res.data)
} catch (error) { }
console.error('Failed to fetch posts:', error) catch (error)
setPosts([]) {
console.error ('Failed to fetch posts:', error)
setPosts ([])
} }
} }
fetchPosts() fetchPosts()
@@ -35,19 +52,15 @@ const PostPage = () => {
return ( return (
<div className="flex flex-wrap gap-4 p-4"> <div className="flex flex-wrap gap-4 p-4">
{posts.map (post => ( {posts.map (post => (
<Link <Link to={`/posts/${ post.id }`}
to={`/posts/${ post.id }`}
key={post.id} key={post.id}
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg">
> <img src={post.thumbnail ?? post.thumbnail_base}
<img className="object-none w-full h-full" />
src={post.thumbnail}
className="object-none w-full h-full"
/>
</Link> </Link>
))} ))}
</div> </div>)
)
} }
export default PostPage export default PostPage