Browse Source

#10, #15 完了

undefined
みてるぞ 1 month ago
parent
commit
5c9d3563e1
8 changed files with 208 additions and 140 deletions
  1. +2
    -2
      backend/app/controllers/posts_controller.rb
  2. +33
    -20
      frontend/src/App.tsx
  3. +30
    -27
      frontend/src/components/NicoViewer.tsx
  4. +7
    -3
      frontend/src/components/TagSearch.tsx
  5. +60
    -39
      frontend/src/components/TagSidebar.tsx
  6. +9
    -11
      frontend/src/components/TopNav.tsx
  7. +31
    -15
      frontend/src/pages/PostDetailPage.tsx
  8. +36
    -23
      frontend/src/pages/PostPage.tsx

+ 2
- 2
backend/app/controllers/posts_controller.rb 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


+ 33
- 20
frontend/src/App.tsx 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>
<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>
<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="/tags/:tag" element={<TagPage />} />
</Routes>
</main>
<Router>
<div className="flex flex-col h-screen w-screen">
<TopNav />
<div className="flex flex-1">
<TagSidebar posts={posts} setPosts={setPosts} />
<main className="flex-1 overflow-y-auto p-4">
<Routes>
<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> </div>
</div>
</Router>
)
</Router>)
} }



export default App export default App

+ 30
- 27
frontend/src/components/NicoViewer.tsx 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 [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 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 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 = () => { 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 = () => { 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;
window.scrollTo(0, 0);
}, [screenWidth, screenHeight, isFullScreen]);
if (!(fullScreen))
return
scrollTo (0, 0)
}, [screenWidth, screenHeight, fullScreen])


return <iframe ref={iframeRef} return <iframe ref={iframeRef}
src={src} src={src}


+ 7
- 3
frontend/src/components/TagSearch.tsx 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"


+ 60
- 39
frontend/src/components/TagSidebar.tsx View File

@@ -4,65 +4,86 @@ import { Link, useParams } from 'react-router-dom'
import { API_BASE_URL } from '../config' import { API_BASE_URL } from '../config'
import TagSearch from './TagSearch' import TagSearch from './TagSearch'


type Tag = { id: number
name: string }
type Tag = { id: number
name: string
category: string }

type TagByCategory = { [key: string]: Tag[] } type TagByCategory = { [key: string]: Tag[] }
type OriginalTag = { id: number
name: string
category: string }

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 } = { const tagNameMap: { [key: string]: string } = {
general: '一般', general: '一般',
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
nico: 'ニコニコタグ' } 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 [tags, setTags] = useState<TagByCategory> ({ })
const [tagsCounts, setTagsCounts] = useState<{ [key: id]: number }> ({ })


useEffect(() => {
useEffect (() => {
const fetchTags = async () => { 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)
{ {
if (!(tag.category in tagsTmp))
tagsTmp[tag.category] = []
tagsTmp[tag.category].push ({ id: tag.id, name: tag.name })
for (const tag of post.tags)
{
if (!(tag.category in tagsTmp))
tagsTmp[tag.category] = []
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) {
console.error('Failed to fetch tags:', error)
setTagsCounts (tagsCountsTmp)
}
catch (error)
{
console.error ('Failed to fetch tags:', error)
} }
} }


fetchTags()
}, [postId])
fetchTags ()
}, [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) ? (
<>
<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"
>
{tag.name}
</Link>
</li>
))}
</ul>
</>
) : <></>)}
</div>
)
{['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">
{tag.name}
</Link>
{posts.length > 1 && <span className="ml-1">{tagsCounts[tag.id]}</span>}
</li>))}
</ul>
</>)}
</div>)
} }


export default TagSidebar export default TagSidebar

+ 9
- 11
frontend/src/components/TopNav.tsx View File

@@ -1,20 +1,18 @@
import React from "react" 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"> <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>
<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>
<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

+ 31
- 15
frontend/src/pages/PostDetailPage.tsx 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[] }

type Props = { posts: Post[]
setPosts: (posts: Post[]) => void }



const PostDetailPage = () => {
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]}
width="640"
height="360" />
) : (
<img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />
)}
</div>
)
{(() => {
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" />)
}
else
return <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />
}) ()}
</div>)
} }



export default PostDetailPage export default PostDetailPage

+ 36
- 23
frontend/src/pages/PostPage.tsx 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 = () => {
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 location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -19,35 +33,34 @@ const PostPage = () => {


useEffect(() => { useEffect(() => {
const fetchPosts = async () => { 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 (','), params: { tags: tags.join (','),
match: (anyFlg ? 'any' : 'all') } }) match: (anyFlg ? 'any' : 'all') } })
setPosts(response.data)
} catch (error) {
console.error('Failed to fetch posts:', error)
setPosts([])
setPosts (res.data)
}
catch (error)
{
console.error ('Failed to fetch posts:', error)
setPosts ([])
} }
} }
fetchPosts() fetchPosts()
}, [location.search]) }, [location.search])


return ( return (
<div className="flex flex-wrap gap-4 p-4">
{posts.map (post => (
<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"
/>
</Link>
))}
</div>
)
<div className="flex flex-wrap gap-4 p-4">
{posts.map (post => (
<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 ?? post.thumbnail_base}
className="object-none w-full h-full" />
</Link>
))}
</div>)
} }



export default PostPage export default PostPage

Loading…
Cancel
Save