| @@ -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) | ||||
| 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) | 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 :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 :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> | |||||
| <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>) | </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 | ||||