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