@@ -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 |
@@ -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 |
@@ -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 | |||
@@ -63,7 +63,7 @@ export default () => { | |||
<Route path="/posts" element={<PostListPage />} /> | |||
<Route path="/posts/new" element={<PostNewPage />} /> | |||
<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/:title" element={<WikiDetailPage />} /> | |||
<Route path="/wiki/new" element={<WikiNewPage />} /> | |||
@@ -166,7 +166,8 @@ export default ({ user }: Props) => { | |||
return ( | |||
<div className={className}> | |||
<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> | |||
</div>) | |||
case Menu.Tag: | |||
@@ -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 | |||
@@ -102,7 +102,7 @@ export default ({ user }: Props) => { | |||
{post.viewed ? '閲覧済' : '未閲覧'} | |||
</Button> | |||
<TabGroup> | |||
{(['admin', 'member'].some (r => r === user?.role) && editing) && ( | |||
{(['admin', 'member'].some (r => user?.role === r) && editing) && ( | |||
<Tab name="編輯"> | |||
<PostEditForm post={post} | |||
onSave={newPost => { | |||
@@ -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 (() => { | |||
@@ -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<NicoTag[]> ([]) | |||
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<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 ( | |||
@@ -43,38 +96,45 @@ export default () => { | |||
</div> | |||
<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> | |||
</MainArea>) | |||
} |