みてるぞ 1 week ago
parent
commit
9e0eb3b3c5
9 changed files with 171 additions and 90 deletions
  1. +19
    -2
      backend/app/controllers/nico_tags_controller.rb
  2. +3
    -26
      backend/app/controllers/posts_controller.rb
  3. +25
    -2
      backend/app/models/tag.rb
  4. +1
    -1
      frontend/src/App.tsx
  5. +2
    -1
      frontend/src/components/TopNav.tsx
  6. +1
    -1
      frontend/src/components/ui/use-toast.tsx
  7. +1
    -1
      frontend/src/pages/posts/PostDetailPage.tsx
  8. +13
    -10
      frontend/src/pages/posts/PostNewPage.tsx
  9. +106
    -46
      frontend/src/pages/tags/NicoTagListPage.tsx

+ 19
- 2
backend/app/controllers/nico_tags_controller.rb View File

@@ -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
backend/app/controllers/posts_controller.rb 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
backend/app/models/tag.rb 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:') ||
(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


+ 1
- 1
frontend/src/App.tsx 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
frontend/src/components/TopNav.tsx 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
frontend/src/components/ui/use-toast.tsx 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
frontend/src/pages/posts/PostDetailPage.tsx 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
frontend/src/pages/posts/PostNewPage.tsx 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: {
'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 (() => {


+ 106
- 46
frontend/src/pages/tags/NicoTagListPage.tsx 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 }> ({ })


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>)
} }

Loading…
Cancel
Save