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