@@ -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 | |||
def index | |||
limit = (params[:limit] || 20).to_i | |||
cursor = params[:cursor] | |||
cursor = params[:cursor].presence | |||
q = filtered_posts.order(created_at: :desc) | |||
next_cursor = nil | |||
if cursor.present? | |||
q = q.where('posts.created_at < ?', Time.iso8601(cursor)) | |||
end | |||
q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor | |||
posts = q.limit(limit + 1) | |||
@@ -1,6 +1,6 @@ | |||
class NicoTagRelation < ApplicationRecord | |||
belongs_to :nico_tag, class_name: 'Tag', foreign_key: 'nico_tag_id' | |||
belongs_to :tag, class_name: 'Tag', foreign_key: 'tag_id' | |||
belongs_to :nico_tag, class_name: 'Tag' | |||
belongs_to :tag, class_name: 'Tag' | |||
validates :nico_tag_id, presence: true | |||
validates :tag_id, presence: true | |||
@@ -14,4 +14,10 @@ class NicoTagRelation < ApplicationRecord | |||
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.' | |||
end | |||
end | |||
def tag_mustnt_be_nico | |||
if tag && tag.category == 'nico' | |||
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.' | |||
end | |||
end | |||
end |
@@ -2,7 +2,14 @@ class Tag < ApplicationRecord | |||
has_many :post_tags, dependent: :destroy | |||
has_many :posts, through: :post_tags | |||
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', | |||
meme: 'meme', | |||
@@ -17,7 +24,7 @@ class Tag < ApplicationRecord | |||
validate :nico_tag_name_must_start_with_nico | |||
scope :nico_tags, -> { where category: :nico } | |||
scope :nico_tags, -> { where(category: :nico) } | |||
def self.tagme | |||
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| | |||
@@ -34,7 +41,8 @@ class Tag < ApplicationRecord | |||
private | |||
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, 'ニコニコ・タグの命名規則に反してゐます.' | |||
end | |||
end | |||
@@ -1,4 +1,6 @@ | |||
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/name/:name', to: 'tags#show_by_name' | |||
get 'posts/random', to: 'posts#random' | |||
@@ -8,6 +8,7 @@ import TagSidebar from '@/components/TagSidebar' | |||
import TopNav from '@/components/TopNav' | |||
import { Toaster } from '@/components/ui/toaster' | |||
import { API_BASE_URL } from '@/config' | |||
import NicoTagListPage from '@/pages/tags/NicoTagListPage' | |||
import NotFound from '@/pages/NotFound' | |||
import PostDetailPage from '@/pages/posts/PostDetailPage' | |||
import PostListPage from '@/pages/posts/PostListPage' | |||
@@ -62,6 +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="/wiki" element={<WikiSearchPage />} /> | |||
<Route path="/wiki/:title" element={<WikiDetailPage />} /> | |||
<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 { Helmet } from 'react-helmet-async' | |||
import { Link } from 'react-router-dom' | |||
import SectionTitle from '@/components/common/SectionTitle' | |||
import MainArea from '@/components/layout/MainArea' | |||
import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
@@ -18,7 +19,7 @@ export default () => { | |||
const search = () => { | |||
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) => { | |||
@@ -32,62 +33,62 @@ export default () => { | |||
return ( | |||
<MainArea> | |||
<Helmet> | |||
<title>{`Wiki | ${ SITE_TITLE }`}</title> | |||
</Helmet> | |||
<div className="max-w-xl"> | |||
<SectionTitle className="text-xl mb-4">Wiki</SectionTitle> | |||
<form onSubmit={handleSearch} className="space-y-2"> | |||
{/* タイトル */} | |||
<div> | |||
<label>タイトル:</label><br /> | |||
<input type="text" | |||
value={title} | |||
onChange={e => setTitle (e.target.value)} | |||
className="border p-1 w-full" /> | |||
</div> | |||
<Helmet> | |||
<title>Wiki | {SITE_TITLE}</title> | |||
</Helmet> | |||
<div className="max-w-xl"> | |||
<SectionTitle>Wiki</SectionTitle> | |||
<form onSubmit={handleSearch} className="space-y-2"> | |||
{/* タイトル */} | |||
<div> | |||
<label>タイトル:</label><br /> | |||
<input type="text" | |||
value={title} | |||
onChange={e => setTitle (e.target.value)} | |||
className="border p-1 w-full" /> | |||
</div> | |||
{/* 内容 */} | |||
<div> | |||
<label>内容:</label><br /> | |||
<input type="text" | |||
value={text} | |||
onChange={e => setText (e.target.value)} | |||
className="border p-1 w-full" /> | |||
</div> | |||
{/* 内容 */} | |||
<div> | |||
<label>内容:</label><br /> | |||
<input type="text" | |||
value={text} | |||
onChange={e => setText (e.target.value)} | |||
className="border p-1 w-full" /> | |||
</div> | |||
{/* 検索 */} | |||
<div className="py-3"> | |||
<button type="submit" | |||
className="bg-blue-500 text-white px-4 py-2 rounded"> | |||
検索 | |||
</button> | |||
</div> | |||
</form> | |||
</div> | |||
{/* 検索 */} | |||
<div className="py-3"> | |||
<button type="submit" | |||
className="bg-blue-500 text-white px-4 py-2 rounded"> | |||
検索 | |||
</button> | |||
</div> | |||
</form> | |||
</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> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{results.map (page => ( | |||
<tr key={page.id}> | |||
<td className="p-2"> | |||
<Link to={`/wiki/${ encodeURIComponent (page.title) }`}> | |||
{page.title} | |||
</Link> | |||
</td> | |||
<td className="p-2 text-gray-100 text-sm"> | |||
{page.updatedAt} | |||
</td> | |||
</tr>))} | |||
</tbody> | |||
</table> | |||
</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> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{results.map (page => ( | |||
<tr key={page.id}> | |||
<td className="p-2"> | |||
<Link to={`/wiki/${ encodeURIComponent (page.title) }`}> | |||
{page.title} | |||
</Link> | |||
</td> | |||
<td className="p-2 text-gray-100 text-sm"> | |||
{page.updatedAt} | |||
</td> | |||
</tr>))} | |||
</tbody> | |||
</table> | |||
</div> | |||
</MainArea>) | |||
} |
@@ -2,6 +2,11 @@ import { CATEGORIES, USER_ROLES } from '@/consts' | |||
export type Category = typeof CATEGORIES[number] | |||
export type NicoTag = { | |||
id: number | |||
name: string | |||
linkedTags: Tag[] } | |||
export type Post = { | |||
id: number | |||
url: string | |||