| @@ -66,7 +66,7 @@ class TagsController < ApplicationController | |||
| .offset(offset) | |||
| .to_a | |||
| render json: { tags: TagRepr.base(tags), count: q.size } | |||
| render json: { tags: TagRepr.many(tags), count: q.size } | |||
| end | |||
| def with_depth | |||
| @@ -209,6 +209,46 @@ class TagsController < ApplicationController | |||
| render json: build_tag_children(tag) | |||
| end | |||
| def update_all | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| tag = Tag.find_by(id: params[:id]) | |||
| return head :not_found unless tag | |||
| name = params[:name].to_s.strip | |||
| category = params[:category].to_s.strip | |||
| return head :unprocessable_entity if name.blank? || category.blank? | |||
| if tag.nico? != (category == 'nico') | |||
| return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, | |||
| status: :unprocessable_entity | |||
| end | |||
| alias_names = params[:aliases].to_s.split.uniq | |||
| parent_names = params[:parent_tags].to_s.split.uniq | |||
| ApplicationRecord.transaction do | |||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | |||
| old_name = tag.name | |||
| tag.update!(category:) | |||
| tag.tag_name.update!(name:) | |||
| alias_names << old_name if name != old_name | |||
| alias_names.delete(name) | |||
| update_aliases!(tag, alias_names) | |||
| update_parent_tags!(tag, parent_names) | |||
| tag.reload | |||
| record_tag_version!(tag, event_type: :update, created_by_user: current_user) | |||
| end | |||
| render json: TagRepr.base(tag.reload) | |||
| end | |||
| def update | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| @@ -237,7 +277,7 @@ class TagsController < ApplicationController | |||
| private | |||
| def build_tag_children(tag) | |||
| def build_tag_children tag | |||
| material = tag.materials.first | |||
| file = nil | |||
| content_type = nil | |||
| @@ -251,11 +291,43 @@ class TagsController < ApplicationController | |||
| material: material.as_json&.merge(file:, content_type:)) | |||
| end | |||
| def record_tag_version!(tag, event_type:, created_by_user:) | |||
| def record_tag_version! tag, event_type:, created_by_user: | |||
| if tag.nico? | |||
| NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | |||
| else | |||
| TagVersionRecorder.record!(tag:, event_type:, created_by_user:) | |||
| end | |||
| end | |||
| def update_aliases! tag, alias_names | |||
| current_aliases = tag.tag_name.aliases.to_a | |||
| current_aliases.each do |alias_tag_name| | |||
| next if alias_names.include?(alias_tag_name.name) | |||
| alias_tag_name.update!(canonical: nil) | |||
| end | |||
| alias_names.each do |alias_name| | |||
| alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) | |||
| alias_tag_name.update!(canonical: tag.tag_name) | |||
| end | |||
| end | |||
| def update_parent_tags! tag, parent_names | |||
| parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, | |||
| with_no_deerjikist: false, | |||
| deny_nico: true) | |||
| TagVersioning.record_tag_snapshots!((tag.parents.to_a + parent_tags).uniq, | |||
| created_by_user: current_user) | |||
| tag.tag_implications.destroy_all | |||
| parent_tags.each do |parent_tag| | |||
| next if parent_tag == tag | |||
| TagImplication.create!(tag:, parent_tag:) | |||
| end | |||
| end | |||
| end | |||
| @@ -8,10 +8,9 @@ module TagRepr | |||
| module_function | |||
| def base tag | |||
| tag.as_json(BASE) | |||
| tag.as_json(BASE).merge(aliases: tag.snapshot_aliases, | |||
| parents: tag.parents.map { _1.as_json(BASE) }) | |||
| end | |||
| def many tags | |||
| tags.map { |t| base(t) } | |||
| end | |||
| def many(tags) = tags.map { |t| base(t) } | |||
| end | |||
| @@ -6,7 +6,7 @@ Rails.application.routes.draw do | |||
| delete ':child_id', action: :destroy | |||
| end | |||
| resources :tags, only: [:index, :show, :update] do | |||
| resources :tags, only: [:index, :show] do | |||
| collection do | |||
| get :autocomplete | |||
| get :'with-depth', action: :with_depth | |||
| @@ -19,6 +19,9 @@ Rails.application.routes.draw do | |||
| end | |||
| member do | |||
| put '', action: :update_all | |||
| patch '', action: :update | |||
| get :deerjikists | |||
| end | |||
| end | |||
| @@ -26,6 +26,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' | |||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import SettingPage from '@/pages/users/SettingPage' | |||
| import TagDetailPage from '@/pages/tags/TagDetailPage' | |||
| import TagListPage from '@/pages/tags/TagListPage' | |||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | |||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | |||
| @@ -55,6 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <Route path="/tags" element={<TagListPage/>}/> | |||
| <Route path="/tags/:name" element={<TagDetailPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | |||
| <Route path="/materials" element={<MaterialBasePage/>}> | |||
| @@ -0,0 +1,133 @@ | |||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||
| import { useEffect, useState } from 'react' | |||
| import { useParams } from 'react-router-dom' | |||
| import Label from '@/components/common/Label' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | |||
| import { apiPut } from '@/lib/api' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTagByName } from '@/lib/tags' | |||
| import type { FC, FormEvent } from 'react' | |||
| import type { Category, Tag } from '@/types' | |||
| export default (() => { | |||
| const { name: nameRaw } = useParams () | |||
| const tagName = String (nameRaw ?? '') | |||
| const tagKey = tagsKeys.show (tagName) | |||
| const { data: tag, isLoading: loading } = useQuery ({ | |||
| queryKey: tagKey, | |||
| queryFn: () => fetchTagByName (tagName) }) | |||
| const [name, setName] = useState ('') | |||
| const [category, setCategory] = useState<Category> ('general') | |||
| const [aliases, setAliases] = useState ('') | |||
| const [parentTags, setParentTags] = useState ('') | |||
| const qc = useQueryClient () | |||
| const handleSubmit = async (e: FormEvent) => { | |||
| e.preventDefault () | |||
| const formData = new FormData | |||
| formData.append ('name', name) | |||
| formData.append ('category', category) | |||
| formData.append ('aliases', aliases) | |||
| formData.append ('parent_tags', parentTags) | |||
| try | |||
| { | |||
| const data = await apiPut<Tag> (`/tags/${ tag?.id }`, formData) | |||
| setName (data.name) | |||
| setCategory (data.category as Category) | |||
| setAliases (data.aliases.join (' ')) | |||
| setParentTags (data.parents.map (t => t.name).join (' ')) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| toast ({ description: '更新しました.' }) | |||
| } | |||
| catch | |||
| { | |||
| toast ({ description: '更新に失敗しました.' }) | |||
| } | |||
| } | |||
| useEffect (() => { | |||
| if (!(tag)) | |||
| return | |||
| setName (tag.name) | |||
| setCategory (tag.category as Category) | |||
| setAliases (tag.aliases?.join (' ')) | |||
| setParentTags (tag.parents?.map (t => t.name).join (' ')) | |||
| }, [tag]) | |||
| return ( | |||
| <MainArea> | |||
| {(loading || !(tag)) ? 'Loading...' : ( | |||
| <div className="max-w-xl"> | |||
| <PageTitle>{tag.name}</PageTitle> | |||
| <form onSubmit={handleSubmit} className="my-4 space-y-2"> | |||
| {/* 名称 */} | |||
| <div> | |||
| <Label>名称</Label> | |||
| <input | |||
| type="text" | |||
| value={name} | |||
| onChange={e => setName (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* カテゴリ */} | |||
| <div> | |||
| <Label>カテゴリ</Label> | |||
| <select | |||
| value={category ?? ''} | |||
| onChange={e => setCategory(e.target.value as Category)} | |||
| className="w-full border p-2 rounded"> | |||
| {CATEGORIES.filter (cat => cat !== 'nico').map (cat => ( | |||
| <option key={cat} value={cat}> | |||
| {CATEGORY_NAMES[cat]} | |||
| </option>))} | |||
| </select> | |||
| </div> | |||
| {/* 別名 */} | |||
| <div> | |||
| <Label>別名</Label> | |||
| <input | |||
| type="text" | |||
| value={aliases} | |||
| onChange={e => setAliases (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 上位タグ */} | |||
| <div> | |||
| <Label>上位タグ</Label> | |||
| <input | |||
| type="text" | |||
| value={parentTags} | |||
| onChange={e => setParentTags (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| <div className="py-3"> | |||
| <button | |||
| type="submit" | |||
| className="bg-blue-500 text-white px-4 py-2 rounded"> | |||
| 更新 | |||
| </button> | |||
| </div> | |||
| </form> | |||
| </div>)} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -260,7 +260,10 @@ export default (() => { | |||
| {results.map (row => ( | |||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | |||
| <td className="p-2"> | |||
| <TagLink tag={row} withCount={false}/> | |||
| <TagLink | |||
| tag={row} | |||
| to={`/tags/${ encodeURIComponent (row.name) }`} | |||
| withCount={false}/> | |||
| </td> | |||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||
| <td className="p-2 text-right">{row.postCount}</td> | |||
| @@ -114,13 +114,15 @@ export default () => { | |||
| {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | |||
| </h1> | |||
| {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} | |||
| </article> | |||
| {(!(version) && posts.length > 0) && ( | |||
| <TabGroup> | |||
| <Tab name="広場"> | |||
| <PostList posts={posts}/> | |||
| </Tab> | |||
| </TabGroup>)} | |||
| {(!(version) && posts.length > 0) && ( | |||
| <div className="not-prose"> | |||
| <TabGroup> | |||
| <Tab name="広場"> | |||
| <PostList posts={posts}/> | |||
| </Tab> | |||
| </TabGroup> | |||
| </div>)} | |||
| </article> | |||
| </MainArea>) | |||
| } | |||
| @@ -165,6 +165,8 @@ export type Tag = { | |||
| id: number | |||
| name: string | |||
| category: Category | |||
| aliases: string[] | |||
| parents: Tag[] | |||
| postCount: number | |||
| createdAt: string | |||
| updatedAt: string | |||