Browse Source

#61

feature/061
みてるぞ 1 week ago
parent
commit
c24ffad7dd
10 changed files with 269 additions and 74 deletions
  1. +1
    -1
      backend/app/controllers/posts_controller.rb
  2. +58
    -5
      backend/app/controllers/tags_controller.rb
  3. +2
    -1
      backend/app/representations/tag_repr.rb
  4. +31
    -0
      frontend/src/components/SortHeader.tsx
  5. +20
    -8
      frontend/src/components/common/Pagination.tsx
  6. +4
    -4
      frontend/src/lib/queryKeys.ts
  7. +10
    -6
      frontend/src/lib/tags.ts
  8. +33
    -28
      frontend/src/pages/posts/PostSearchPage.tsx
  9. +93
    -20
      frontend/src/pages/tags/TagListPage.tsx
  10. +17
    -1
      frontend/src/types.ts

+ 1
- 1
backend/app/controllers/posts_controller.rb View File

@@ -75,7 +75,7 @@ class PostsController < ApplicationController
else
"posts.#{ order[0] }"
end
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }"))
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a


+ 58
- 5
backend/app/controllers/tags_controller.rb View File

@@ -2,18 +2,71 @@ class TagsController < ApplicationController
def index
post_id = params[:post]

tags =
name = params[:name].presence
category = params[:category].presence
post_count_between = (params[:post_count_gte].presence || -1).to_i,
(params[:post_count_lte].presence || -1).to_i
post_count_between[0] = nil if post_count_between[0] < 0
post_count_between[1] = nil if post_count_between[1] < 0
created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_to].presence

order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
order[0] = 'post_count'
end
unless order[1].in?(['asc', 'desc'])
order[1] = order[0].in?(['name', 'category']) ? 'asc' : 'desc'
end

page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i

page = 1 if page < 1
limit = 1 if limit < 1

offset = (page - 1) * limit

q =
if post_id.present?
Tag.joins(:posts, :tag_name)
else
Tag.joins(:tag_name)
end
.includes(:tag_name, tag_name: :wiki_page)
if post_id.present?
tags = tags.where(posts: { id: post_id })
end
q = q.where(posts: { id: post_id }) if post_id.present?

q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category: category) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]

sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
"WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END"
else
"tags.#{ order[0] }"
end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a

render json: TagRepr.base(tags)
render json: { tags: TagRepr.base(tags), count: q.size }
end

def autocomplete


+ 2
- 1
backend/app/representations/tag_repr.rb View File

@@ -2,7 +2,8 @@


module TagRepr
BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki] }.freeze

module_function



+ 31
- 0
frontend/src/components/SortHeader.tsx View File

@@ -0,0 +1,31 @@
import { useLocation } from 'react-router-dom'

import PrefetchLink from '@/components/PrefetchLink'


export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
by: T
label: string
currentOrder: `${ T }:${ 'asc' | 'desc' }`
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
const [fld, dir] = currentOrder.split (':')

const location = useLocation ()
const qs = new URLSearchParams (location.search)
const nextDir =
(by === fld)
? (dir === 'asc' ? 'desc' : 'asc')
: (defaultDirection[by] || 'desc')
qs.set ('order', `${ by }:${ nextDir }`)
qs.set ('page', '1')

return (
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
</span>
</PrefetchLink>)
}

+ 20
- 8
frontend/src/components/common/Pagination.tsx View File

@@ -48,7 +48,7 @@ const getPages = (
}


export default (({ page, totalPages, siblingCount = 4 }) => {
export default (({ page, totalPages, siblingCount = 2 }) => {
const location = useLocation ()

const buildTo = (p: number) => {
@@ -63,19 +63,31 @@ export default (({ page, totalPages, siblingCount = 4 }) => {
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<div className="flex items-center gap-2">
{(page > 1)
? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ">&lt;</PrefetchLink>
: <span aria-hidden>&lt;</span>}
? (
<PrefetchLink
className="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
&lt;
</PrefetchLink>)
: <span className="p-2" aria-hidden>&lt;</span>}

{pages.map ((p, idx) => (
(p === '…')
? <span key={`dots-${ idx }`}>…</span>
? <span key={`dots-${ idx }`} className="p-2">…</span>
: ((p === page)
? <span key={p} className="font-bold" aria-current="page">{p}</span>
: <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))}
? <span key={p} className="font-bold p-2" aria-current="page">{p}</span>
: <PrefetchLink key={p} className="p-2" to={buildTo (p)}>{p}</PrefetchLink>)))}

{(page < totalPages)
? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">&gt;</PrefetchLink>
: <span aria-hidden>&gt;</span>}
? (
<PrefetchLink
className="p-2"
to={buildTo (page + 1)}
aria-label="次のページ">
&gt;
</PrefetchLink>)
: <span className="p-2" aria-hidden>&gt;</span>}
</div>
</nav>)
}) satisfies FC<Props>

+ 4
- 4
frontend/src/lib/queryKeys.ts View File

@@ -1,4 +1,4 @@
import type { FetchPostsParams } from '@/types'
import type { FetchPostsParams, FetchTagsParams } from '@/types'

export const postsKeys = {
root: ['posts'] as const,
@@ -9,9 +9,9 @@ export const postsKeys = {
['posts', 'changes', p] as const }

export const tagsKeys = {
root: ['tags'] as const,
index: (p) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const }
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const }

export const wikiKeys = {
root: ['wiki'] as const,


+ 10
- 6
frontend/src/lib/tags.ts View File

@@ -1,22 +1,26 @@
import { apiGet } from '@/lib/api'

import type { Tag } from '@/types'
import type { FetchTagsParams, Tag } from '@/types'


export const fetchTags = async (
{ name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo },
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
...(post != null && { post }),
...(name && { name }),
...(category && { category }),
...(postCountGTE && { post_count_gte: postCountGTE }),
...(postCountLTE && { post_count_lte: postCountLTE }),
...(postCountGTE != null && { post_count_gte: postCountGTE }),
...(postCountLTE != null && { post_count_lte: postCountLTE }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }) } })
...(updatedTo && { updated_to: updatedTo }),
...(page && { page }),
...(limit && { limit }),
...(order && { order }) } })


export const fetchTagByName = async (name: string): Promise<Tag | null> => {


+ 33
- 28
frontend/src/pages/posts/PostSearchPage.tsx View File

@@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'

import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField'
@@ -102,28 +103,6 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])

const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => {
const [fld, dir] = order.split (':')

const qs = new URLSearchParams (location.search)
const nextDir =
(by === fld)
? (dir === 'asc' ? 'desc' : 'asc')
: (['title', 'url'].includes (by) ? 'asc' : 'desc')
qs.set ('order', `${ by }:${ nextDir }`)
qs.set ('page', '1')

return (
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
</span>
</PrefetchLink>)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)
@@ -188,7 +167,7 @@ export default (() => {
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
qs.set ('match', matchType)
qs.set ('page', String ('1'))
qs.set ('page', '1')
qs.set ('order', order)
navigate (`${ location.pathname }?${ qs.toString () }`)
}
@@ -207,6 +186,12 @@ export default (() => {
search ()
}

const defaultDirection = { title: 'asc',
url: 'asc',
original_created_at: 'desc',
created_at: 'desc',
updated_at: 'desc' } as const

return (
<MainArea>
<Helmet>
@@ -339,20 +324,40 @@ export default (() => {
<tr>
<th className="p-2 text-left whitespace-nowrap">投稿</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="title" label="タイトル"/>
<SortHeader<FetchPostsOrderField>
by="title"
label="タイトル"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="url" label="URL"/>
<SortHeader<FetchPostsOrderField>
by="url"
label="URL"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">タグ</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
<SortHeader<FetchPostsOrderField>
by="original_created_at"
label="オリジナルの投稿日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="created_at" label="投稿日時"/>
<SortHeader<FetchPostsOrderField>
by="created_at"
label="投稿日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="updated_at" label="更新日時"/>
<SortHeader<FetchPostsOrderField>
by="updated_at"
label="更新日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
</tr>
</thead>


+ 93
- 20
frontend/src/pages/tags/TagListPage.tsx View File

@@ -1,23 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'

import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchTags } from '@/lib/tags'
import { dateString } from '@/lib/utils'

import type { FC } from 'react'
import type { FC, FormEvent } from 'react'

import type { Category, FetchTagsOrder } from '@/types'
import type { Category, FetchTagsOrder, FetchTagsOrderField } from '@/types'


const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
const t = v?.trim ()
if (t)
qs.set (k, t)
}


export default (() => {
const location = useLocation ()

const navigate = useNavigate ()

const query = useMemo (() => new URLSearchParams (location.search), [location.search])

const page = Number (query.get ('page') ?? 1)
@@ -26,7 +41,8 @@ export default (() => {
const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null
const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1)
const qPostCountLTE = Number (query.get ('post_count_lte') ?? (-1))
const qPostCountLTE =
query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null
const qCreatedFrom = query.get ('created_from') ?? ''
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
@@ -36,17 +52,28 @@ export default (() => {
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null)
const [postCountGTE, setPostCountGTE] = useState (1)
const [postCountLTE, setPostCountLTE] = useState (-1)
const [postCountLTE, setPostCountLTE] = useState<number | null> (null)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)

const keys = { name: qName, category: qCategory }
const keys = {
page, limit, order,
post: null,
name: qName,
category: qCategory,
postCountGTE: qPostCountGTE,
postCountLTE: qPostCountLTE,
createdFrom: qCreatedFrom,
createdTo: qCreatedTo,
updatedFrom: qUpdatedFrom,
updatedTo: qUpdatedTo }
const { data, isLoading: loading } = useQuery ({
queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) })
const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0

useEffect (() => {
setName (qName)
@@ -57,12 +84,36 @@ export default (() => {
setCreatedTo (qCreatedTo)
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
}, [])

const handleSearch = () => {
;
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])

const handleSearch = (e: FormEvent) => {
e.preventDefault ()

const qs = new URLSearchParams ()
setIf (qs, 'name', name)
setIf (qs, 'category', category)
if (postCountGTE !== 1)
qs.set ('post_count_gte', String (postCountGTE))
if (postCountLTE != null)
qs.set ('post_count_lte', String (postCountLTE))
setIf (qs, 'created_from', createdFrom)
setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
qs.set ('page', '1')
qs.set ('order', order)

navigate (`${ location.pathname }?${ qs.toString () }`)
}

const defaultDirection = { name: 'asc',
category: 'asc',
post_count: 'desc',
created_at: 'desc',
updated_at: 'desc' } as const

return (
<MainArea>
<Helmet>
@@ -88,7 +139,7 @@ export default (() => {
<Label>カテゴリ</Label>
<select
value={category ?? ''}
onChange={e => setCategory(e.target.value || null)}
onChange={e => setCategory((e.target.value || null) as Category | null)}
className="w-full border p-2 rounded">
<option value="">&nbsp;</option>
{CATEGORIES.map (cat => (
@@ -104,15 +155,15 @@ export default (() => {
<input
type="number"
min="0"
value={postCountGTE < 0 ? '' : String (postCountGTE)}
onChange={e => setPostCountGTE (Number (e.target.value || (-1)))}
value={postCountGTE < 0 ? 0 : String (postCountGTE)}
onChange={e => setPostCountGTE (Number (e.target.value || 0))}
className="border rounded p-2"/>
<span className="mx-1">〜</span>
<input
type="number"
min="0"
value={postCountLTE < 0 ? '' : String (postCountLTE)}
onChange={e => setPostCountLTE (Number (e.target.value || (-1)))}
value={postCountLTE == null ? '' : String (postCountLTE)}
onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)}
className="border rounded p-2"/>
</div>

@@ -165,19 +216,39 @@ export default (() => {
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="name" label="タグ"/>
<SortHeader<FetchTagsOrderField>
by="name"
label="タグ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="category" label="カテゴリ"/>
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="post_count" label="件数"/>
<SortHeader<FetchTagsOrderField>
by="post_count"
label="件数"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="created_at" label="最初の記載日時"/>
<SortHeader<FetchTagsOrderField>
by="created_at"
label="最初の記載日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="updated_at" label="更新日時"/>
<SortHeader<FetchTagsOrderField>
by="updated_at"
label="更新日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
</tr>
</thead>
@@ -189,13 +260,15 @@ export default (() => {
<TagLink tag={row} withCount={false}/>
</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2">{dateString (row.postCount)}</td>
<td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
</tr>))}
</tbody>
</table>
</div>

<Pagination page={page} totalPages={totalPages}/>
</div>) : '結果ないよ(笑)')}
</MainArea>)
}) satisfies FC

+ 17
- 1
frontend/src/types.ts View File

@@ -32,9 +32,23 @@ export type FetchTagsOrderField =
| 'name'
| 'category'
| 'post_count'
| 'create_at'
| 'created_at'
| 'updated_at'

export type FetchTagsParams = {
post: number | null
name: string
category: Category | null
postCountGTE: number
postCountLTE: number | null
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
page: number
limit: number
order: FetchTagsOrder }

export type Menu = MenuItem[]

export type MenuItem = {
@@ -80,6 +94,8 @@ export type Tag = {
name: string
category: Category
postCount: number
createdAt: string
updatedAt: string
hasWiki: boolean
children?: Tag[]
matchedAlias?: string | null }


Loading…
Cancel
Save