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