This commit is contained in:
@@ -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:') ||
|
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
|
||||||
|
|||||||
@@ -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: {
|
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 (() => {
|
||||||
|
|||||||
@@ -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>)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user