This commit is contained in:
2025-07-13 17:18:02 +09:00
parent 0c46cf28db
commit 9e0eb3b3c5
9 changed files with 173 additions and 92 deletions
@@ -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
+3 -26
View File
@@ -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
+25 -2
View File
@@ -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:') || if ((category == 'nico' && !(name.start_with?('nico:'))) ||
(category != 'nico' && name&.[](0, 5) == 'nico:')) (category != 'nico' && name.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end
+1 -1
View File
@@ -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 />} />
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 => {
+13 -10
View File
@@ -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: { try
'Content-Type': 'multipart/form-data', {
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) await axios.post (`${ API_BASE_URL }/posts`, formData, { headers: {
.then (() => { 'Content-Type': 'multipart/form-data',
toast ({ title: '投稿成功!' }) 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
navigate ('/posts') toast ({ title: '投稿成功!' })
}) navigate ('/posts')
.catch (() => toast ({ title: '投稿失敗', }
description: '入力を確認してください。' }))) catch
{
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
}
} }
useEffect (() => { useEffect (() => {
+108 -48
View File
@@ -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 }> ({ })
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 }))
setLoading (false)
}
const handleEdit = async (id: number) => {
if (editing[id])
{
const formData = new FormData
formData.append ('tags', rawTags[id])
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 (() => { useEffect (() => {
void (async () => { setNicoTags ([])
const res = await axios.get (`${ API_BASE_URL }/tags/nico`) loadMore (false)
const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[] }
setNicoTags (data.tags)
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 }))
}) ()
}, []) }, [])
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"> {nicoTags.length > 0 && (
<thead> <table className="table-auto w-full border-collapse mb-4">
<tr> <thead>
<th className="p-2 text-left"></th> <tr>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th></th> <th className="p-2 text-left"></th>
</tr> {memberFlg && <th></th>}
</thead> </tr>
<tbody> </thead>
{nicoTags.map (tag => ( <tbody>
<tr key={tag.id}> {nicoTags.map (tag => (
<td className="p-2"> <tr key={tag.id}>
{tag.name} <td className="p-2">
</td> <a href={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
<td className="p-2"> target="_blank">
{editing[tag.id] {tag.name}
? ( </a>
<TextArea value={rawTags[tag.id]} onChange={ev => { </td>
setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) <td className="p-2">
}} />) {editing[tag.id]
: rawTags[tag.id]} ? (
</td> <TextArea value={rawTags[tag.id]} onChange={ev => {
<td> setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value }))
<a href="#" onClick={() => { }} />)
setEditing (editing => ({ ...editing, [tag.id]: !(editing[tag.id]) })) : rawTags[tag.id]}
}}> </td>
{editing[tag.id] ? <span className="text-red-400"></span> : '編輯'} {memberFlg && (
</a> <td>
</td> <a href="#" onClick={() => handleEdit (tag.id)}>
</tr>))} {editing[tag.id]
</tbody> ? <span className="text-red-400"></span>
</table> : <span></span>}
</a>
</td>)}
</tr>))}
</tbody>
</table>)}
{loading && 'Loading...'}
<div ref={loaderRef} className="h-12"></div>
</div> </div>
</MainArea>) </MainArea>)
} }