Browse Source

#21 完了

undefined
みてるぞ 1 month ago
parent
commit
a0943f598a
4 changed files with 156 additions and 25 deletions
  1. +36
    -13
      backend/app/controllers/tags_controller.rb
  2. +1
    -0
      backend/config/routes.rb
  3. +79
    -12
      frontend/src/components/TagSearch.tsx
  4. +40
    -0
      frontend/src/components/TagSearchBox.tsx

+ 36
- 13
backend/app/controllers/tags_controller.rb View File

@@ -2,14 +2,36 @@ class TagsController < ApplicationController
before_action :set_tags, only: %i[ show update destroy ]

def index
if params[:post].present?
@tags = Tag.joins(:posts).where(posts: { id: params[:post] })
else
@tags = Tag.all
end
@tags =
if params[:post].present?
Tag.joins(:posts).where(posts: { id: params[:post] })
else
Tag.all
end
render json: @tags
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
render json: @tag
end
@@ -24,13 +46,14 @@ class TagsController < ApplicationController
end

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.
def tag_params
params.expect(tag: [ :title, :body ])
end
# 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.
def tag_params
params.expect(tag: [ :title, :body ])
end
end

+ 1
- 0
backend/config/routes.rb View File

@@ -24,6 +24,7 @@ Rails.application.routes.draw do
get "tags/create"
get "tags/update"
get "tags/destroy"
get 'tags/autocomplete', to: 'tags#autocomplete'
get "tag_aliases/index"
get "tag_aliases/show"
get "tag_aliases/create"


+ 79
- 12
frontend/src/components/TagSearch.tsx View File

@@ -2,6 +2,12 @@ import React, { useEffect, useState } from 'react'
import axios from 'axios'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { API_BASE_URL } from '../config'
import TagSearchBox from './TagSearchBox'

type Tag = { id: number
name: string
category: string
count?: number }


const TagSearch: React.FC = () => {
@@ -9,11 +15,68 @@ const TagSearch: React.FC = () => {
const location = useLocation ()

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>) => {
if (e.key === 'Enter' && search.length > 0)
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`,
{ replace: true })
switch (e.key)
{
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 (() => {
@@ -23,15 +86,19 @@ const TagSearch: React.FC = () => {
}, [location.search])

return (
<input
type="text"
placeholder="タグ検索..."
value={search}
onChange={e => setSearch (e.target.value)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 mb-4 border rounded"
/>
)
<div className="relative w-full mb-4">
<input type="text"
placeholder="タグ検索..."
value={search}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
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

+ 40
- 0
frontend/src/components/TagSearchBox.tsx View File

@@ -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

Loading…
Cancel
Save