diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9b8bbe9..aed029f 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,3 +1,7 @@ +require 'net/http' +require 'uri' + + class TagsController < ApplicationController def index post_id = params[:post] @@ -182,7 +186,8 @@ class TagsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } end def deerjikists_by_name @@ -194,7 +199,31 @@ class TagsController < ApplicationController .find_by(tag_names: { name: }) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } + end + + def update_deerjikists + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(id: params[:id]) + return head :not_found unless tag + + ApplicationRecord.transaction do + tag.deerjikists = [] + params[:_json].each do + platform = _1[:platform] + code = normalise_deerjikist_code(platform, _1[:code]) + deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) + deerjikist.tag = tag + deerjikist.save! + end + end + + render json: DeerjikistRepr.many(tag.reload.deerjikists) end def materials_by_name @@ -391,4 +420,21 @@ class TagsController < ApplicationController TagImplication.create!(tag:, parent_tag:) end end + + def normalise_deerjikist_code platform, code + return code if platform != 'youtube' || code[0] != '@' + + url = "https://www.youtube.com/#{ code }" + + html = Net::HTTP.get(URI(url)) + + canonical = html[ + /), + ) + end + + it 'normalises youtube handle to channel id' do + expect { + do_request + }.to change { Deerjikist.where(tag: tag).count }.from(0).to(1) + + expect(response).to have_http_status(:ok) + + expect(Net::HTTP).to have_received(:get) + + expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag)) + .to eq(true) + + expect(json).to be_a(Array) + expect(json.size).to eq(1) + expect(json[0]['platform']).to eq('youtube') + expect(json[0]['code']).to eq(channel_id) + end end end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f44d7d..f52209d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 884c851..a68f8a9 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -45,9 +45,9 @@ export default (({ tag, <> {(linkFlg && withWiki) && ( - {(tag.materialId != null || tag.hasWiki) + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( - tag.materialId == null + tag.materialId == null && !(tag.hasDeerjikists) ? ( ) : ( - - ? - )) + tag.materialId != null + ? ( + + ? + ) + : ( + + ? + ))) : ( ['character', 'material'].includes (tag.category) ? ( @@ -71,13 +79,23 @@ export default (({ tag, ! ) : ( - - ! - ))} + tag.category === 'deerjikist' + ? ( + + ! + ) + : ( + + ! + )))} )} {nestLevel > 0 && ( = + { nico: 'ニコニコ', youtube: 'YouTube' } as const + export const TAG_COLOUR = { deerjikist: 'rose', meme: 'purple', diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 97bae56..6ac3f21 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,11 +9,12 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p: FetchTagsParams) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const, - changes: (p: { id?: string; page: number; limit: number }) => - ['tags', 'changes', p] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const, + changes: (p: { id?: string; page: number; limit: number }) => + ['tags', 'changes', p] as const, + deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index e2c95c3..74eba45 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag, TagVersion } from '@/types' +import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -56,3 +56,9 @@ export const fetchTagChanges = async ( versions: TagVersion[] count: number }> => await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) + + +export const fetchDeerjikistsByTag = async ( + id: string, +): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> => + await apiGet (`/tags/${ id }/deerjikists`) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx new file mode 100644 index 0000000..ee3651f --- /dev/null +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -0,0 +1,155 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +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 { PLATFORM_NAMES, PLATFORMS } from '@/consts' +import { apiPut } from '@/lib/api' +import { tagsKeys } from '@/lib/queryKeys' +import { fetchDeerjikistsByTag } from '@/lib/tags' +import { cn } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + +import type { Deerjikist, Platform } from '@/types' + + +export default (() => { + const { id } = useParams () + const tagId = String (id ?? '') + const tagKey = tagsKeys.deerjikists (tagId) + + const { data: qData, isLoading: loading } = + useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) + const tag = qData?.tag + const deerjikists = qData?.deerjikists ?? [] + + const [data, setData] = + useState<(Omit & { platform: Platform | null })[]> ([]) + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + try + { + setDisabled (true) + + setData (await apiPut (`/tags/${ id }/deerjikists`, data)) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) + } + finally + { + setDisabled (false) + } + } + + useEffect (() => { + if (!(tag)) + { + setDisabled (true) + return + } + + setData (deerjikists) + setDisabled (false) + }, [tag, deerjikists]) + + return ( + + {(loading || !(tag)) ? 'Loading...' : ( + + + + + + + {data.map ((datum, i) => ( + + + setData (prev => [...prev.slice (0, i), + ...prev.slice (i + 1)])}> + #{i + 1} + + + + {/* プラットフォーム */} + + プラットフォーム + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], + platform: (e.target.value || null) as Platform | null } + return rtn + })}> + + {PLATFORMS.map (p => ( + + {PLATFORM_NAMES[p]} + ))} + + + + {/* コード */} + + コード + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], code: e.target.value } + return rtn + })}/> + + + ))} + + + setData (prev => [...prev, { platform: null, code: '' }])}> + + + + + + + + 更新 + + + + + )} + ) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 51363c3..5fb8078 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,6 @@ import { CATEGORIES, FETCH_POSTS_ORDER_FIELDS, + PLATFORMS, USER_ROLES, ViewFlagBehavior } from '@/consts' @@ -7,6 +8,8 @@ import type { ReactNode } from 'react' export type Category = typeof CATEGORIES[number] +export type Deerjikist = { platform: Platform; code: string } + export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] @@ -114,6 +117,8 @@ export type NiconicoViewerHandle = { showComments: () => void hideComments: () => void } +export type Platform = typeof PLATFORMS[number] + export type Post = { id: number url: string @@ -178,7 +183,8 @@ export type Tag = { createdAt: string updatedAt: string hasWiki: boolean - materialId: number + materialId: number | null + hasDeerjikists: boolean children?: Tag[] matchedAlias?: string | null }