From 9e0eb3b3c5d7b0dd2eb9cda055b36f596ab69a73 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 13 Jul 2025 17:18:02 +0900 Subject: [PATCH] #77 --- .../app/controllers/nico_tags_controller.rb | 21 ++- backend/app/controllers/posts_controller.rb | 29 +--- backend/app/models/tag.rb | 27 +++- frontend/src/App.tsx | 2 +- frontend/src/components/TopNav.tsx | 3 +- frontend/src/components/ui/use-toast.tsx | 2 +- frontend/src/pages/posts/PostDetailPage.tsx | 2 +- frontend/src/pages/posts/PostNewPage.tsx | 23 +-- frontend/src/pages/tags/NicoTagListPage.tsx | 152 ++++++++++++------ 9 files changed, 171 insertions(+), 90 deletions(-) diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index a3c611d..9b1b06a 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -3,7 +3,7 @@ class NicoTagsController < ApplicationController limit = (params[:limit] || 20).to_i cursor = params[:cursor].presence - q = Tag.nico_tags.includes(:linked_tags) + q = Tag.nico_tags.includes(:linked_tags).order(updated_at: :desc) q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor tags = q.limit(limit + 1) @@ -19,6 +19,23 @@ class NicoTagsController < ApplicationController }, next_cursor: } end - def upload + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.member? + + id = params[:id].to_i + + tag = Tag.find(id) + return head :bad_request if tag.category != 'nico' + + linked_tag_names = params[:tags].to_s.split(' ') + linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false) + return head :bad_request if linked_tags.filter { |t| t.category == 'nico' }.present? + + tag.linked_tags = linked_tags + tag.updated_at = Time.now + tag.update! + + render json: tag.linked_tags, status: :ok end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index f401882..60f3577 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -63,7 +63,7 @@ class PostsController < ApplicationController post.thumbnail.attach(thumbnail) if post.save post.resized_thumbnail! - post.tags = normalise_tags(tags_names) + post.tags = Tag.normalise_tags(tags_names) render json: post.as_json(include: { tags: { only: [:id, :name, :category] } }), status: :created else @@ -94,10 +94,10 @@ class PostsController < ApplicationController tag_names = params[:tags].to_s.split(' ') post = Post.find(params[:id].to_i) - tags = post.tags.where(category: 'nico').to_a + normalise_tags(tag_names) + tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names) if post.update(title:, tags:) render json: post.as_json(include: { tags: { only: [:id, :name, :category] } }), - status: :created + status: :ok else render json: post.errors, status: :unprocessable_entity end @@ -109,14 +109,6 @@ class PostsController < ApplicationController private - CATEGORY_PREFIXES = { - 'gen:' => 'general', - 'djk:' => 'deerjikist', - 'meme:' => 'meme', - 'chr:' => 'character', - 'mtr:' => 'material', - 'meta:' => 'meta' }.freeze - def filtered_posts tag_names = params[:tags]&.split(' ') match_type = params[:match] @@ -134,19 +126,4 @@ class PostsController < ApplicationController end posts.distinct end - - def normalise_tags tag_names - tags = tag_names.map do |name| - pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] - name.delete_prefix!(pf) - Tag.find_or_initialize_by(name:).tap do |tag| - if cat && tag.category != cat - tag.category = cat - tag.save! - end - end - end - tags << Tag.tagme if tags.size < 20 && tags.none?(Tag.tagme) - tags.uniq - end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index a8239f6..137afff 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -26,6 +26,14 @@ class Tag < ApplicationRecord scope :nico_tags, -> { where(category: :nico) } + CATEGORY_PREFIXES = { + 'gen:' => 'general', + 'djk:' => 'deerjikist', + 'meme:' => 'meme', + 'chr:' => 'character', + 'mtr:' => 'material', + 'meta:' => 'meta' }.freeze + def self.tagme @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| tag.category = 'meta' @@ -38,11 +46,26 @@ class Tag < ApplicationRecord end end + def self.normalise_tags tag_names, with_tagme: true + tags = tag_names.map do |name| + pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] + name.delete_prefix!(pf) + Tag.find_or_initialize_by(name:).tap do |tag| + if cat && tag.category != cat + tag.category = cat + tag.save! + end + end + end + tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme) + tags.uniq + end + private def nico_tag_name_must_start_with_nico - if ((category == 'nico' && name&.[](0, 5) != 'nico:') || - (category != 'nico' && name&.[](0, 5) == 'nico:')) + if ((category == 'nico' && !(name.start_with?('nico:'))) || + (category != 'nico' && name.start_with?('nico:'))) errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 691fc67..0e8f93f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,7 +63,7 @@ export default () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index bcd3ab2..70af9c2 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -166,7 +166,8 @@ export default ({ user }: Props) => { return (
一覧 - 投稿追加 + {['admin', 'member'].some (r => user?.role === r) && ( + 投稿追加)} ヘルプ
) case Menu.Tag: diff --git a/frontend/src/components/ui/use-toast.tsx b/frontend/src/components/ui/use-toast.tsx index 83c24fd..43c3e4b 100644 --- a/frontend/src/components/ui/use-toast.tsx +++ b/frontend/src/components/ui/use-toast.tsx @@ -6,7 +6,7 @@ import * as React from "react" import type { ToastActionElement, ToastProps, -} from "@/registry/default/ui/toast" +} from "./toast" const TOAST_LIMIT = 1 const TOAST_REMOVE_DELAY = 1000000 diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 86293e5..20ac87d 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -102,7 +102,7 @@ export default ({ user }: Props) => { {post.viewed ? '閲覧済' : '未閲覧'} - {(['admin', 'member'].some (r => r === user?.role) && editing) && ( + {(['admin', 'member'].some (r => user?.role === r) && editing) && ( { diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index a6e67dc..349ce99 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -28,22 +28,25 @@ export default () => { const previousURLRef = useRef ('') const handleSubmit = async () => { - const formData = new FormData () + const formData = new FormData formData.append ('title', title) formData.append ('url', url) formData.append ('tags', tags) if (thumbnailFile) formData.append ('thumbnail', thumbnailFile) - void (axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { - 'Content-Type': 'multipart/form-data', - 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) - .then (() => { - toast ({ title: '投稿成功!' }) - navigate ('/posts') - }) - .catch (() => toast ({ title: '投稿失敗', - description: '入力を確認してください。' }))) + try + { + await axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { + 'Content-Type': 'multipart/form-data', + 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) + toast ({ title: '投稿成功!' }) + navigate ('/posts') + } + catch + { + toast ({ title: '投稿失敗', description: '入力を確認してください。' }) + } } useEffect (() => { diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx index 72ac3dc..b9959d8 100644 --- a/frontend/src/pages/tags/NicoTagListPage.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.tsx @@ -1,35 +1,88 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import SectionTitle from '@/components/common/SectionTitle' import TextArea from '@/components/common/TextArea' import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' import { API_BASE_URL, SITE_TITLE } from '@/config' -import type { NicoTag } from '@/types' +import type { NicoTag, Tag, User } from '@/types' +type Props = { user: User | null } -export default () => { + +export default ({ user }: Props) => { const [nicoTags, setNicoTags] = useState ([]) + const [cursor, setCursor] = useState ('') + const [loading, setLoading] = useState (false) const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ }) - useEffect (() => { - void (async () => { - const res = await axios.get (`${ API_BASE_URL }/tags/nico`) - const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[] } + const loaderRef = useRef (null) + + const memberFlg = ['admin', 'member'].some (r => user?.role === r) + + const loadMore = async (withCursor: boolean) => { + setLoading (true) + + const res = await axios.get (`${ API_BASE_URL }/tags/nico`, { + params: { ...(withCursor ? { cursor } : { }) } }) + const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[] + nextCursor: string } + + setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) + setCursor (data.nextCursor) + + const newEditing = Object.fromEntries (data.tags.map (t => [t.id, false])) + setEditing (editing => ({ ...editing, ...newEditing })) + + const newRawTags = Object.fromEntries ( + data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) + setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) - setNicoTags (data.tags) + setLoading (false) + } - const newEditing = Object.fromEntries (data.tags.map (t => [t.id, false])) - setEditing (editing => ({ ...editing, ...newEditing })) + const handleEdit = async (id: number) => { + if (editing[id]) + { + const formData = new FormData + formData.append ('tags', rawTags[id]) - const newRawTags = Object.fromEntries ( - data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) - setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) - }) () + const res = await axios.put (`${ API_BASE_URL }/tags/nico/${ id }`, formData, { headers: { + 'Content-Type': 'multipart/form-data', + 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) + const data = toCamel (res.data as any, { deep: true }) as Tag[] + setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') })) + + toast ({ title: '更新しました.' }) + } + + setEditing (editing => ({ ...editing, [id]: !(editing[id]) })) + } + + useEffect(() => { + const observer = new IntersectionObserver (entries => { + if (entries[0].isIntersecting && !(loading) && cursor) + loadMore (true) + }, { threshold: 1 }) + + const target = loaderRef.current + if (target) + observer.observe (target) + + return () => { + if (target) + observer.unobserve (target) + } + }, [loaderRef, loading]) + + useEffect (() => { + setNicoTags ([]) + loadMore (false) }, []) return ( @@ -43,38 +96,45 @@ export default () => {
- - - - - - - - - - {nicoTags.map (tag => ( - - -
ニコニコタグ連携タグ
- {tag.name} - - {editing[tag.id] - ? ( -