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