This commit is contained in:
2025-07-11 02:05:16 +09:00
parent b83fc6369a
commit 3c89d14636
9 changed files with 191 additions and 66 deletions
@@ -0,0 +1,24 @@
class NicoTagsController < ApplicationController
def index
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
q = Tag.nico_tags.includes(:linked_tags)
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
tags = q.limit(limit + 1)
next_cursor = nil
if tags.size > limit
next_cursor = tags.last.updated_at.iso8601(6)
tags = tags.first(limit)
end
render json: { tags: tags.map { |tag|
tag.as_json(include: :linked_tags)
}, next_cursor: }
end
def upload
end
end
+2 -6
View File
@@ -6,14 +6,10 @@ class PostsController < ApplicationController
# GET /posts # GET /posts
def index def index
limit = (params[:limit] || 20).to_i limit = (params[:limit] || 20).to_i
cursor = params[:cursor] cursor = params[:cursor].presence
q = filtered_posts.order(created_at: :desc) q = filtered_posts.order(created_at: :desc)
q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor
next_cursor = nil
if cursor.present?
q = q.where('posts.created_at < ?', Time.iso8601(cursor))
end
posts = q.limit(limit + 1) posts = q.limit(limit + 1)
+8 -2
View File
@@ -1,6 +1,6 @@
class NicoTagRelation < ApplicationRecord class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, class_name: 'Tag', foreign_key: 'nico_tag_id' belongs_to :nico_tag, class_name: 'Tag'
belongs_to :tag, class_name: 'Tag', foreign_key: 'tag_id' belongs_to :tag, class_name: 'Tag'
validates :nico_tag_id, presence: true validates :nico_tag_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
@@ -14,4 +14,10 @@ class NicoTagRelation < ApplicationRecord
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.' errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end end
end end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end end
+11 -3
View File
@@ -2,7 +2,14 @@ class Tag < ApplicationRecord
has_many :post_tags, dependent: :destroy has_many :post_tags, dependent: :destroy
has_many :posts, through: :post_tags has_many :posts, through: :post_tags
has_many :tag_aliases, dependent: :destroy has_many :tag_aliases, dependent: :destroy
has_many :wiki_pages, dependent: :nullify
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :reversed_nico_tag_relations, class_name: 'NicoTagRelation',
foreign_key: :tag_id,
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
enum :category, { deerjikist: 'deerjikist', enum :category, { deerjikist: 'deerjikist',
meme: 'meme', meme: 'meme',
@@ -17,7 +24,7 @@ class Tag < ApplicationRecord
validate :nico_tag_name_must_start_with_nico validate :nico_tag_name_must_start_with_nico
scope :nico_tags, -> { where category: :nico } scope :nico_tags, -> { where(category: :nico) }
def self.tagme def self.tagme
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag|
@@ -34,7 +41,8 @@ class Tag < ApplicationRecord
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&.[](0, 5) != 'nico:') ||
(category != 'nico' && name&.[](0, 5) == 'nico:'))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end
+2
View File
@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
get 'tags/nico', to: 'nico_tags#index'
put 'tags/nico/:id', to: 'nico_tags#update'
get 'tags/autocomplete', to: 'tags#autocomplete' get 'tags/autocomplete', to: 'tags#autocomplete'
get 'tags/name/:name', to: 'tags#show_by_name' get 'tags/name/:name', to: 'tags#show_by_name'
get 'posts/random', to: 'posts#random' get 'posts/random', to: 'posts#random'
+2
View File
@@ -8,6 +8,7 @@ import TagSidebar from '@/components/TagSidebar'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
@@ -62,6 +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="/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 />} />
@@ -0,0 +1,81 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom'
import SectionTitle from '@/components/common/SectionTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config'
import type { NicoTag } from '@/types'
export default () => {
const [nicoTags, setNicoTags] = useState<NicoTag[]> ([])
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, { deep: true })
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 (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<SectionTitle></SectionTitle>
</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={ev => {
setEditing (editing => ({ ...editing, [tag.id]: !(editing[tag.id]) }))
}}>
{editing[tag.id] ? <span className="text-red-400"></span> : '編輯'}
</a>
</td>
</tr>))}
</tbody>
</table>
</div>
</MainArea>)
}
+56 -55
View File
@@ -3,6 +3,7 @@ import toCamel from 'camelcase-keys'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config' import { API_BASE_URL, SITE_TITLE } from '@/config'
@@ -18,7 +19,7 @@ export default () => {
const search = () => { const search = () => {
void (axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) void (axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } })
.then (res => setResults (toCamel (res.data, { deep: true })))) .then (res => setResults (toCamel (res.data, { deep: true }))))
} }
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
@@ -32,62 +33,62 @@ export default () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`Wiki | ${ SITE_TITLE }`}</title> <title>Wiki | {SITE_TITLE}</title>
</Helmet> </Helmet>
<div className="max-w-xl"> <div className="max-w-xl">
<SectionTitle className="text-xl mb-4">Wiki</SectionTitle> <SectionTitle>Wiki</SectionTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
<div> <div>
<label></label><br /> <label></label><br />
<input type="text" <input type="text"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className="border p-1 w-full" /> className="border p-1 w-full" />
</div> </div>
{/* 内容 */} {/* 内容 */}
<div> <div>
<label></label><br /> <label></label><br />
<input type="text" <input type="text"
value={text} value={text}
onChange={e => setText (e.target.value)} onChange={e => setText (e.target.value)}
className="border p-1 w-full" /> className="border p-1 w-full" />
</div> </div>
{/* 検索 */} {/* 検索 */}
<div className="py-3"> <div className="py-3">
<button type="submit" <button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded"> className="bg-blue-500 text-white px-4 py-2 rounded">
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<table className="table-auto w-full border-collapse"> <table className="table-auto w-full border-collapse">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{results.map (page => ( {results.map (page => (
<tr key={page.id}> <tr key={page.id}>
<td className="p-2"> <td className="p-2">
<Link to={`/wiki/${ encodeURIComponent (page.title) }`}> <Link to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title} {page.title}
</Link> </Link>
</td> </td>
<td className="p-2 text-gray-100 text-sm"> <td className="p-2 text-gray-100 text-sm">
{page.updatedAt} {page.updatedAt}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
</table> </table>
</div> </div>
</MainArea>) </MainArea>)
} }
+5
View File
@@ -2,6 +2,11 @@ import { CATEGORIES, USER_ROLES } from '@/consts'
export type Category = typeof CATEGORIES[number] export type Category = typeof CATEGORIES[number]
export type NicoTag = {
id: number
name: string
linkedTags: Tag[] }
export type Post = { export type Post = {
id: number id: number
url: string url: string