This commit is contained in:
@@ -5,16 +5,26 @@ require 'nokogiri'
|
|||||||
class PostsController < ApplicationController
|
class PostsController < ApplicationController
|
||||||
# GET /posts
|
# GET /posts
|
||||||
def index
|
def index
|
||||||
latest_id = params[:latest].to_i
|
limit = (params[:limit] || 20).to_i
|
||||||
offset = params[:offset].to_i
|
cursor = params[:cursor]
|
||||||
|
|
||||||
posts = filtered_posts.order(created_at: :desc)
|
q = filtered_posts.order(created_at: :desc)
|
||||||
posts = if posts.first&.id == latest_id
|
|
||||||
posts.limit(20).offset(offset)
|
next_cursor = nil
|
||||||
else
|
if cursor.present?
|
||||||
posts.limit(offset + 20)
|
q = q.where('posts.created_at < ?', Time.iso8601(cursor))
|
||||||
end
|
end
|
||||||
render json: posts.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
|
|
||||||
|
posts = q.limit(limit + 1)
|
||||||
|
|
||||||
|
next_cursor = nil
|
||||||
|
if posts.size > limit
|
||||||
|
next_cursor = posts.last.created_at.iso8601(6)
|
||||||
|
posts = posts.first(limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { posts: posts.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }),
|
||||||
|
next_cursor: }
|
||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
@@ -31,7 +41,10 @@ class PostsController < ApplicationController
|
|||||||
# GET /posts/1
|
# GET /posts/1
|
||||||
def show
|
def show
|
||||||
post = Post.includes(:tags).find(params[:id])
|
post = Post.includes(:tags).find(params[:id])
|
||||||
viewed = current_user&.viewed?(post)
|
return head :not_found unless post
|
||||||
|
|
||||||
|
viewed = current_user&.viewed?(post) || false
|
||||||
|
|
||||||
render json: (post
|
render json: (post
|
||||||
.as_json(include: { tags: { only: [:id, :name, :category] } })
|
.as_json(include: { tags: { only: [:id, :name, :category] } })
|
||||||
.merge(viewed: viewed))
|
.merge(viewed: viewed))
|
||||||
@@ -95,7 +108,7 @@ class PostsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def filtered_posts
|
def filtered_posts
|
||||||
tag_names = params[:tags]&.split(',')
|
tag_names = params[:tags]&.split(?\ )
|
||||||
match_type = params[:match]
|
match_type = params[:match]
|
||||||
tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all
|
tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TagSearch from '@/components/TagSearch'
|
|||||||
import SectionTitle from '@/components/common/SectionTitle'
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
|
import { CATEGORIES } from '@/consts'
|
||||||
|
|
||||||
import type { Post, Tag } from '@/types'
|
import type { Post, Tag } from '@/types'
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ export default ({ posts }: Props) => {
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const tagsTmp: TagByCategory = { }
|
const tagsTmp: TagByCategory = { }
|
||||||
|
let cnt = 0
|
||||||
|
loop:
|
||||||
for (const post of posts)
|
for (const post of posts)
|
||||||
{
|
{
|
||||||
for (const tag of post.tags)
|
for (const tag of post.tags)
|
||||||
@@ -34,7 +37,12 @@ export default ({ posts }: Props) => {
|
|||||||
if (!(tag.category in tagsTmp))
|
if (!(tag.category in tagsTmp))
|
||||||
tagsTmp[tag.category] = []
|
tagsTmp[tag.category] = []
|
||||||
if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id)))
|
if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id)))
|
||||||
|
{
|
||||||
tagsTmp[tag.category].push (tag)
|
tagsTmp[tag.category].push (tag)
|
||||||
|
++cnt
|
||||||
|
if (cnt >= 25)
|
||||||
|
break loop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const cat of Object.keys (tagsTmp))
|
for (const cat of Object.keys (tagsTmp))
|
||||||
@@ -47,7 +55,7 @@ export default ({ posts }: Props) => {
|
|||||||
<TagSearch />
|
<TagSearch />
|
||||||
<SectionTitle>タグ</SectionTitle>
|
<SectionTitle>タグ</SectionTitle>
|
||||||
<ul>
|
<ul>
|
||||||
{['general', 'deerjikist', 'nico'].map (cat => cat in tags && (
|
{CATEGORIES.map (cat => cat in tags && (
|
||||||
<>
|
<>
|
||||||
{tags[cat].map (tag => (
|
{tags[cat].map (tag => (
|
||||||
<li key={tag.id} className="mb-1">
|
<li key={tag.id} className="mb-1">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import toCamel from 'camelcase-keys'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
import { Link, useLocation, useParams } from 'react-router-dom'
|
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||||
@@ -32,8 +33,8 @@ export default ({ user }: Props) => {
|
|||||||
void (axios.delete (
|
void (axios.delete (
|
||||||
`${ API_BASE_URL }/posts/${ id }/viewed`,
|
`${ API_BASE_URL }/posts/${ id }/viewed`,
|
||||||
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
||||||
.then (res => setPost (post => ({ ...post, viewed: false })))
|
.then (() => setPost (post => ({ ...post, viewed: false })))
|
||||||
.catch (err => toast ({ title: '失敗……',
|
.catch (() => toast ({ title: '失敗……',
|
||||||
description: '通信に失敗しました……' })))
|
description: '通信に失敗しました……' })))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -42,8 +43,8 @@ export default ({ user }: Props) => {
|
|||||||
`${ API_BASE_URL }/posts/${ id }/viewed`,
|
`${ API_BASE_URL }/posts/${ id }/viewed`,
|
||||||
{ },
|
{ },
|
||||||
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
||||||
.then (res => setPost (post => ({ ...post, viewed: true })))
|
.then (() => setPost (post => ({ ...post, viewed: true })))
|
||||||
.catch (err => toast ({ title: '失敗……',
|
.catch (() => toast ({ title: '失敗……',
|
||||||
description: '通信に失敗しました……' })))
|
description: '通信に失敗しました……' })))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ export default ({ user }: Props) => {
|
|||||||
return
|
return
|
||||||
void (axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: {
|
void (axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: {
|
||||||
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
|
||||||
.then (res => setPost (res.data))
|
.then (res => setPost (toCamel (res.data, { deep: true })))
|
||||||
.catch (err => console.error ('うんち!', err)))
|
.catch (err => console.error ('うんち!', err)))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ export default ({ user }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
{(post?.thumbnail || post?.thumbnailBase) &&
|
||||||
|
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase} />}
|
||||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<TagDetailSidebar post={post} />
|
<TagDetailSidebar post={post} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import toCamel from 'camelcase-keys'
|
import toCamel from 'camelcase-keys'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -16,6 +16,22 @@ import type { Post, Tag, WikiPage } from '@/types'
|
|||||||
export default () => {
|
export default () => {
|
||||||
const [posts, setPosts] = useState<Post[] | null> (null)
|
const [posts, setPosts] = useState<Post[] | null> (null)
|
||||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||||
|
const [cursor, setCursor] = useState ('')
|
||||||
|
const [loading, setLoading] = useState (false)
|
||||||
|
|
||||||
|
const loaderRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setLoading (true)
|
||||||
|
const res = await axios.get (`${ API_BASE_URL }/posts`, {
|
||||||
|
params: { tags: tags.join (' '),
|
||||||
|
match: (anyFlg ? 'any' : 'all'),
|
||||||
|
cursor } })
|
||||||
|
const data = toCamel (res.data, { deep: true })
|
||||||
|
setPosts (posts => [...(posts || []), ...data.posts])
|
||||||
|
setCursor (data.nextCursor)
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
@@ -24,14 +40,22 @@ export default () => {
|
|||||||
const tags = tagsQuery.split (' ').filter (e => e !== '')
|
const tags = tagsQuery.split (' ').filter (e => e !== '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (axios.get (`${ API_BASE_URL }/posts`, {
|
const observer = new IntersectionObserver (entries => {
|
||||||
params: { tags: tags.join (','),
|
if (entries[0].isIntersecting && !(loading) && cursor)
|
||||||
match: (anyFlg ? 'any' : 'all') } })
|
loadMore ()
|
||||||
.then (res => setPosts (toCamel (res.data, { deep: true })))
|
}, { threshold: 1 })
|
||||||
.catch (err => {
|
|
||||||
console.error ('Failed to fetch posts:', err)
|
const target = loaderRef.current
|
||||||
setPosts ([])
|
target && observer.observe (target)
|
||||||
}))
|
|
||||||
|
return () => target && observer.unobserve (target)
|
||||||
|
}, [loaderRef, loading])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
console.log ('test')
|
||||||
|
setCursor ('')
|
||||||
|
setPosts (null)
|
||||||
|
loadMore ()
|
||||||
|
|
||||||
setWikiPage (null)
|
setWikiPage (null)
|
||||||
if (tags.length === 1)
|
if (tags.length === 1)
|
||||||
@@ -50,10 +74,10 @@ export default () => {
|
|||||||
<title>
|
<title>
|
||||||
{tags.length
|
{tags.length
|
||||||
? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }`
|
? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }`
|
||||||
: `${ SITE_TITLE } 〜 ぼざクリも、ぼざろ外も、外伝もあるんだよ`}
|
: `${ SITE_TITLE } 〜 ぼざクリ関聯綜合リンク集サイト`}
|
||||||
</title>
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<TagSidebar posts={posts || []} />
|
<TagSidebar posts={posts?.slice (0, 20) || []} />
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<TabGroup key={wikiPage}>
|
<TabGroup key={wikiPage}>
|
||||||
<Tab name="広場">
|
<Tab name="広場">
|
||||||
@@ -66,10 +90,12 @@ export default () => {
|
|||||||
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.thumbnailBase}
|
<img src={post.thumbnail ?? post.thumbnailBase}
|
||||||
|
alt={post.title || post.url}
|
||||||
className="object-none w-full h-full" />
|
className="object-none w-full h-full" />
|
||||||
</Link>))}
|
</Link>))}
|
||||||
</div>)
|
</div>)
|
||||||
: '広場には何もありませんよ.')}
|
: '広場には何もありませんよ.')}
|
||||||
|
<div ref={loaderRef} className="h-12"></div>
|
||||||
</Tab>
|
</Tab>
|
||||||
{(wikiPage && wikiPage.body) && (
|
{(wikiPage && wikiPage.body) && (
|
||||||
<Tab name="Wiki" init={posts && !(posts.length)}>
|
<Tab name="Wiki" init={posts && !(posts.length)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user