#21 完了
This commit is contained in:
@@ -2,14 +2,36 @@ class TagsController < ApplicationController
|
|||||||
before_action :set_tags, only: %i[ show update destroy ]
|
before_action :set_tags, only: %i[ show update destroy ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
if params[:post].present?
|
@tags =
|
||||||
@tags = Tag.joins(:posts).where(posts: { id: params[:post] })
|
if params[:post].present?
|
||||||
else
|
Tag.joins(:posts).where(posts: { id: params[:post] })
|
||||||
@tags = Tag.all
|
else
|
||||||
end
|
Tag.all
|
||||||
|
end
|
||||||
render json: @tags
|
render json: @tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def autocomplete
|
||||||
|
q = params[:q].to_s.strip
|
||||||
|
return render json: [] if q.blank?
|
||||||
|
|
||||||
|
tags = (Tag
|
||||||
|
.left_joins(:posts)
|
||||||
|
.select('tags.id, tags.name, tags.category, COUNT(posts.id) AS post_count')
|
||||||
|
.where('(tags.category = ? AND tags.name LIKE ?)
|
||||||
|
OR tags.name LIKE ?',
|
||||||
|
'nico', "nico:#{ q }%", "#{ q }%")
|
||||||
|
.group('tags.id')
|
||||||
|
.order('post_count DESC, tags.name ASC')
|
||||||
|
.limit(20))
|
||||||
|
render json: tags.map do |tag|
|
||||||
|
{ id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
count: tag.post_count }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @tag
|
render json: @tag
|
||||||
end
|
end
|
||||||
@@ -24,13 +46,14 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Use callbacks to share common setup or constraints between actions.
|
|
||||||
def set_tag
|
|
||||||
@tag = Tag.find(params.expect(:id))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
# Use callbacks to share common setup or constraints between actions.
|
||||||
def tag_params
|
def set_tag
|
||||||
params.expect(tag: [ :title, :body ])
|
@tag = Tag.find(params.expect(:id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only allow a list of trusted parameters through.
|
||||||
|
def tag_params
|
||||||
|
params.expect(tag: [ :title, :body ])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Rails.application.routes.draw do
|
|||||||
get "tags/create"
|
get "tags/create"
|
||||||
get "tags/update"
|
get "tags/update"
|
||||||
get "tags/destroy"
|
get "tags/destroy"
|
||||||
|
get 'tags/autocomplete', to: 'tags#autocomplete'
|
||||||
get "tag_aliases/index"
|
get "tag_aliases/index"
|
||||||
get "tag_aliases/show"
|
get "tag_aliases/show"
|
||||||
get "tag_aliases/create"
|
get "tag_aliases/create"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { API_BASE_URL } from '../config'
|
import { API_BASE_URL } from '../config'
|
||||||
|
import TagSearchBox from './TagSearchBox'
|
||||||
|
|
||||||
|
type Tag = { id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
count?: number }
|
||||||
|
|
||||||
|
|
||||||
const TagSearch: React.FC = () => {
|
const TagSearch: React.FC = () => {
|
||||||
@@ -9,11 +15,68 @@ const TagSearch: React.FC = () => {
|
|||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
|
const whenChanged = e => {
|
||||||
|
setSearch (e.target.value)
|
||||||
|
|
||||||
|
const q: string = e.target.value.split (' ').at (-1)
|
||||||
|
if (!(q))
|
||||||
|
{
|
||||||
|
setSuggestions ([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
|
||||||
|
.then (res => {
|
||||||
|
setSuggestions (res.data)
|
||||||
|
if (suggestions.length)
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter' && search.length > 0)
|
switch (e.key)
|
||||||
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`,
|
{
|
||||||
{ replace: true })
|
case 'ArrowDown':
|
||||||
|
e.preventDefault ()
|
||||||
|
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault ()
|
||||||
|
setActiveIndex (i => Math.max (i - 1, -1))
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
if (activeIndex < 0)
|
||||||
|
break
|
||||||
|
e.preventDefault ()
|
||||||
|
const selected = suggestions[activeIndex]
|
||||||
|
selected && handleTagSelect (selected)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault ()
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && search.length && (!(suggestionsVsbl) || activeIndex < 0))
|
||||||
|
{
|
||||||
|
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTagSelect = (tag: Tag) => {
|
||||||
|
const parts = search.split (' ')
|
||||||
|
parts[parts.length - 1] = tag.name
|
||||||
|
setSearch (parts.join (' ') + ' ')
|
||||||
|
setSuggestions ([])
|
||||||
|
setActiveIndex (-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -23,15 +86,19 @@ const TagSearch: React.FC = () => {
|
|||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className="relative w-full mb-4">
|
||||||
type="text"
|
<input type="text"
|
||||||
placeholder="タグ検索..."
|
placeholder="タグ検索..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch (e.target.value)}
|
onChange={whenChanged}
|
||||||
onKeyDown={handleKeyDown}
|
onFocus={() => setSuggestionsVsbl (true)}
|
||||||
className="w-full px-3 py-2 mb-4 border rounded"
|
onBlur={() => setSuggestionsVsbl (false)}
|
||||||
/>
|
onKeyDown={handleKeyDown}
|
||||||
)
|
className="w-full px-3 py-2 border rounded border-gray-600 bg-gray-800 text-white" />
|
||||||
|
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length && suggestions}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onSelect={handleTagSelect} />
|
||||||
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagSearch
|
export default TagSearch
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { API_BASE_URL } from '../config'
|
||||||
|
|
||||||
|
type Tag = { id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
count?: number }
|
||||||
|
|
||||||
|
type Props = { suggestions: Tag[]
|
||||||
|
activeIndex: number
|
||||||
|
onSelect: (tag: Tag) => void }
|
||||||
|
|
||||||
|
|
||||||
|
const TagSearchBox: React.FC = (props: Props) => {
|
||||||
|
const { suggestions, activeIndex, onSelect } = props
|
||||||
|
|
||||||
|
if (!(suggestions.length))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
const location = useLocation ()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="absolute left-0 right-0 z-50 w-full bg-gray-800 border border-gray-600 rounded shadow">
|
||||||
|
{suggestions.map ((tag, i) => (
|
||||||
|
<li key={tag.id}
|
||||||
|
className={`px-3 py-2 cursor-pointer ${
|
||||||
|
i === activeIndex ? 'bg-blue-600 text-white' : 'hover:bg-gray-700' }`}
|
||||||
|
onMouseDown={() => onSelect (tag)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{<span className="ml-2 text-sm text-gray-400">{tag.count}</span>}
|
||||||
|
</li>))}
|
||||||
|
</ul>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default TagSearchBox
|
||||||
Reference in New Issue
Block a user