Browse Source

#10, #15 完了

#23
みてるぞ 1 month ago
parent
commit
41668fa894
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
@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


+ 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 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>
<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>
</Router>
)
</Router>)
}


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 [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
- 3
frontend/src/components/TagSearch.tsx View File

@@ -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"


+ 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 TagSearch from './TagSearch'

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

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 } = {
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(() => {
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)
{
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)
} 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 (
<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) ? (
<>
<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

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

@@ -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

+ 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 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[] }

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


const PostDetailPage = () => {
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]}
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

+ 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 { 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,35 +33,34 @@ 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) {
console.error('Failed to fetch posts:', error)
setPosts([])
setPosts (res.data)
}
catch (error)
{
console.error ('Failed to fetch posts:', error)
setPosts ([])
}
}
fetchPosts()
}, [location.search])

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

Loading…
Cancel
Save