| @@ -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 | |||||
| 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 | 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,9 +33,9 @@ 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 }))) | |||||
| .catch (err => toast ({ title: '失敗……', | |||||
| description: '通信に失敗しました……' }))) | |||||
| .then (() => setPost (post => ({ ...post, viewed: false }))) | |||||
| .catch (() => toast ({ title: '失敗……', | |||||
| description: '通信に失敗しました……' }))) | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| @@ -42,9 +43,9 @@ 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 }))) | |||||
| .catch (err => toast ({ title: '失敗……', | |||||
| description: '通信に失敗しました……' }))) | |||||
| .then (() => setPost (post => ({ ...post, viewed: true }))) | |||||
| .catch (() => toast ({ title: '失敗……', | |||||
| 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`, { | |||||
| 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) | 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)}> | ||||