From a0943f598acebc475c98ef72c7f767f202daea4c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 27 May 2025 01:40:13 +0900 Subject: [PATCH] =?UTF-8?q?#21=20=E5=AE=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/controllers/tags_controller.rb | 49 ++++++++---- backend/config/routes.rb | 1 + frontend/src/components/TagSearch.tsx | 91 +++++++++++++++++++--- frontend/src/components/TagSearchBox.tsx | 40 ++++++++++ 4 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/TagSearchBox.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 23a0391..df78f7b 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 2c339eb..ca99178 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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" diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index f177efa..a6b51c3 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -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 ([]) + 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) => { - 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 ( - setSearch (e.target.value)} - onKeyDown={handleKeyDown} - className="w-full px-3 py-2 mb-4 border rounded" - /> - ) +
+ setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border rounded border-gray-600 bg-gray-800 text-white" /> + +
) } export default TagSearch diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx new file mode 100644 index 0000000..8ec9714 --- /dev/null +++ b/frontend/src/components/TagSearchBox.tsx @@ -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 ( +
    + {suggestions.map ((tag, i) => ( +
  • onSelect (tag)} + > + {tag.name} + {{tag.count}} +
  • ))} +
) +} + + +export default TagSearchBox