| @@ -3,7 +3,7 @@ class NicoTagsController < ApplicationController | |||||
| limit = (params[:limit] || 20).to_i | limit = (params[:limit] || 20).to_i | ||||
| cursor = params[:cursor].presence | 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 | q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor | ||||
| tags = q.limit(limit + 1) | tags = q.limit(limit + 1) | ||||
| @@ -19,6 +19,23 @@ class NicoTagsController < ApplicationController | |||||
| }, next_cursor: } | }, next_cursor: } | ||||
| end | 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 | ||||
| end | end | ||||
| @@ -63,7 +63,7 @@ class PostsController < ApplicationController | |||||
| post.thumbnail.attach(thumbnail) | post.thumbnail.attach(thumbnail) | ||||
| if post.save | if post.save | ||||
| post.resized_thumbnail! | 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] } }), | render json: post.as_json(include: { tags: { only: [:id, :name, :category] } }), | ||||
| status: :created | status: :created | ||||
| else | else | ||||
| @@ -94,10 +94,10 @@ class PostsController < ApplicationController | |||||
| tag_names = params[:tags].to_s.split(' ') | tag_names = params[:tags].to_s.split(' ') | ||||
| post = Post.find(params[:id].to_i) | 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:) | if post.update(title:, tags:) | ||||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category] } }), | render json: post.as_json(include: { tags: { only: [:id, :name, :category] } }), | ||||
| status: :created | |||||
| status: :ok | |||||
| else | else | ||||
| render json: post.errors, status: :unprocessable_entity | render json: post.errors, status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -109,14 +109,6 @@ class PostsController < ApplicationController | |||||
| private | private | ||||
| CATEGORY_PREFIXES = { | |||||
| 'gen:' => 'general', | |||||
| 'djk:' => 'deerjikist', | |||||
| 'meme:' => 'meme', | |||||
| 'chr:' => 'character', | |||||
| 'mtr:' => 'material', | |||||
| 'meta:' => 'meta' }.freeze | |||||
| def filtered_posts | def filtered_posts | ||||
| tag_names = params[:tags]&.split(' ') | tag_names = params[:tags]&.split(' ') | ||||
| match_type = params[:match] | match_type = params[:match] | ||||
| @@ -134,19 +126,4 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| posts.distinct | posts.distinct | ||||
| end | 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 | end | ||||
| @@ -26,6 +26,14 @@ class Tag < ApplicationRecord | |||||
| scope :nico_tags, -> { where(category: :nico) } | scope :nico_tags, -> { where(category: :nico) } | ||||
| CATEGORY_PREFIXES = { | |||||
| 'gen:' => 'general', | |||||
| 'djk:' => 'deerjikist', | |||||
| 'meme:' => 'meme', | |||||
| 'chr:' => 'character', | |||||
| 'mtr:' => 'material', | |||||
| 'meta:' => 'meta' }.freeze | |||||
| def self.tagme | def self.tagme | ||||
| @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| | @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| | ||||
| tag.category = 'meta' | tag.category = 'meta' | ||||
| @@ -38,11 +46,26 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| 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 | private | ||||
| def nico_tag_name_must_start_with_nico | 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, 'ニコニコ・タグの命名規則に反してゐます.' | errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' | ||||
| end | end | ||||
| end | end | ||||
| @@ -63,7 +63,7 @@ export default () => { | |||||
| <Route path="/posts" element={<PostListPage />} /> | <Route path="/posts" element={<PostListPage />} /> | ||||
| <Route path="/posts/new" element={<PostNewPage />} /> | <Route path="/posts/new" element={<PostNewPage />} /> | ||||
| <Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | <Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | ||||
| <Route path="/tags/nico" element={<NicoTagListPage />} /> | |||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user} />} /> | |||||
| <Route path="/wiki" element={<WikiSearchPage />} /> | <Route path="/wiki" element={<WikiSearchPage />} /> | ||||
| <Route path="/wiki/:title" element={<WikiDetailPage />} /> | <Route path="/wiki/:title" element={<WikiDetailPage />} /> | ||||
| <Route path="/wiki/new" element={<WikiNewPage />} /> | <Route path="/wiki/new" element={<WikiNewPage />} /> | ||||
| @@ -166,7 +166,8 @@ export default ({ user }: Props) => { | |||||
| return ( | return ( | ||||
| <div className={className}> | <div className={className}> | ||||
| <Link to="/posts" className={subClass}>一覧</Link> | <Link to="/posts" className={subClass}>一覧</Link> | ||||
| <Link to="/posts/new" className={subClass}>投稿追加</Link> | |||||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||||
| <Link to="/posts/new" className={subClass}>投稿追加</Link>)} | |||||
| <Link to="/wiki/ヘルプ:広場" className={subClass}>ヘルプ</Link> | <Link to="/wiki/ヘルプ:広場" className={subClass}>ヘルプ</Link> | ||||
| </div>) | </div>) | ||||
| case Menu.Tag: | case Menu.Tag: | ||||
| @@ -6,7 +6,7 @@ import * as React from "react" | |||||
| import type { | import type { | ||||
| ToastActionElement, | ToastActionElement, | ||||
| ToastProps, | ToastProps, | ||||
| } from "@/registry/default/ui/toast" | |||||
| } from "./toast" | |||||
| const TOAST_LIMIT = 1 | const TOAST_LIMIT = 1 | ||||
| const TOAST_REMOVE_DELAY = 1000000 | const TOAST_REMOVE_DELAY = 1000000 | ||||
| @@ -102,7 +102,7 @@ export default ({ user }: Props) => { | |||||
| {post.viewed ? '閲覧済' : '未閲覧'} | {post.viewed ? '閲覧済' : '未閲覧'} | ||||
| </Button> | </Button> | ||||
| <TabGroup> | <TabGroup> | ||||
| {(['admin', 'member'].some (r => r === user?.role) && editing) && ( | |||||
| {(['admin', 'member'].some (r => user?.role === r) && editing) && ( | |||||
| <Tab name="編輯"> | <Tab name="編輯"> | ||||
| <PostEditForm post={post} | <PostEditForm post={post} | ||||
| onSave={newPost => { | onSave={newPost => { | ||||
| @@ -28,22 +28,25 @@ export default () => { | |||||
| const previousURLRef = useRef ('') | const previousURLRef = useRef ('') | ||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const formData = new FormData () | |||||
| const formData = new FormData | |||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('url', url) | formData.append ('url', url) | ||||
| formData.append ('tags', tags) | formData.append ('tags', tags) | ||||
| if (thumbnailFile) | if (thumbnailFile) | ||||
| formData.append ('thumbnail', 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 (() => { | useEffect (() => { | ||||
| @@ -1,35 +1,88 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useEffect, useState } from 'react' | |||||
| import { useEffect, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | |||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | 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<NicoTag[]> ([]) | const [nicoTags, setNicoTags] = useState<NicoTag[]> ([]) | ||||
| const [cursor, setCursor] = useState ('') | |||||
| const [loading, setLoading] = useState (false) | |||||
| const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | ||||
| const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ }) | 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<HTMLDivElement | null> (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 ( | return ( | ||||
| @@ -43,38 +96,45 @@ export default () => { | |||||
| </div> | </div> | ||||
| <div className="mt-4"> | <div className="mt-4"> | ||||
| <table className="table-auto w-full border-collapse"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th className="p-2 text-left">ニコニコタグ</th> | |||||
| <th className="p-2 text-left">連携タグ</th> | |||||
| <th></th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {nicoTags.map (tag => ( | |||||
| <tr key={tag.id}> | |||||
| <td className="p-2"> | |||||
| {tag.name} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {editing[tag.id] | |||||
| ? ( | |||||
| <TextArea value={rawTags[tag.id]} onChange={ev => { | |||||
| setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) | |||||
| }} />) | |||||
| : rawTags[tag.id]} | |||||
| </td> | |||||
| <td> | |||||
| <a href="#" onClick={() => { | |||||
| setEditing (editing => ({ ...editing, [tag.id]: !(editing[tag.id]) })) | |||||
| }}> | |||||
| {editing[tag.id] ? <span className="text-red-400">更新</span> : '編輯'} | |||||
| </a> | |||||
| </td> | |||||
| </tr>))} | |||||
| </tbody> | |||||
| </table> | |||||
| {nicoTags.length > 0 && ( | |||||
| <table className="table-auto w-full border-collapse mb-4"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th className="p-2 text-left">ニコニコタグ</th> | |||||
| <th className="p-2 text-left">連携タグ</th> | |||||
| {memberFlg && <th></th>} | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {nicoTags.map (tag => ( | |||||
| <tr key={tag.id}> | |||||
| <td className="p-2"> | |||||
| <a href={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||||
| target="_blank"> | |||||
| {tag.name} | |||||
| </a> | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {editing[tag.id] | |||||
| ? ( | |||||
| <TextArea value={rawTags[tag.id]} onChange={ev => { | |||||
| setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) | |||||
| }} />) | |||||
| : rawTags[tag.id]} | |||||
| </td> | |||||
| {memberFlg && ( | |||||
| <td> | |||||
| <a href="#" onClick={() => handleEdit (tag.id)}> | |||||
| {editing[tag.id] | |||||
| ? <span className="text-red-400">更新</span> | |||||
| : <span>編輯</span>} | |||||
| </a> | |||||
| </td>)} | |||||
| </tr>))} | |||||
| </tbody> | |||||
| </table>)} | |||||
| {loading && 'Loading...'} | |||||
| <div ref={loaderRef} className="h-12"></div> | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||