| @@ -6,12 +6,12 @@ class PostsController < ApplicationController | |||
| # GET /posts | |||
| def index | |||
| posts = filtered_posts | |||
| render json: posts.as_json(include: { tags: { only: [:id, :name, :category] } }) | |||
| render json: posts.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | |||
| end | |||
| def random | |||
| post = filtered_posts.order('RAND()').first | |||
| viewed = current_user&.viewed?(post) | |||
| viewed = current_user&.viewed?(post) || false | |||
| render json: (post | |||
| .as_json(include: { tags: { only: [:id, :name, :category] } }) | |||
| .merge(viewed: viewed)) | |||
| @@ -1,11 +1,11 @@ | |||
| class TagsController < ApplicationController | |||
| def index | |||
| tags = | |||
| if params[:post].present? | |||
| Tag.joins(:posts).where(posts: { id: params[:post] }) | |||
| else | |||
| Tag.all | |||
| end | |||
| post_id = params[:post] | |||
| tags = if post_id.present? | |||
| Tag.joins(:posts).where(posts: { id: post_id }) | |||
| else | |||
| Tag.all | |||
| end | |||
| render json: tags | |||
| end | |||
| @@ -14,23 +14,25 @@ class TagsController < ApplicationController | |||
| return render json: [] if q.blank? | |||
| tags = (Tag | |||
| .left_joins(:posts) | |||
| .select('tags.id, tags.name, tags.category, COUNT(posts.id) AS post_count') | |||
| .where('(tags.category = ? AND tags.name LIKE ?) | |||
| OR tags.name LIKE ?', | |||
| .where('(category = ? AND name LIKE ?) OR name LIKE ?', | |||
| 'nico', "nico:#{ q }%", "#{ q }%") | |||
| .group('tags.id') | |||
| .order('post_count DESC, tags.name ASC') | |||
| .order('post_count DESC, name ASC') | |||
| .limit(20)) | |||
| render json: tags.map { |tag| { | |||
| id: tag.id, | |||
| name: tag.name, | |||
| category: tag.category, | |||
| count: tag.post_count } } | |||
| render json: tags | |||
| end | |||
| def show | |||
| render json: Tag.find(params[:id]) | |||
| tag = Tag.find(params[:id]) | |||
| render json: tag | |||
| end | |||
| def show_by_name | |||
| tag = Tag.find_by(name: params[:name]) | |||
| if tag | |||
| render json: tag | |||
| else | |||
| head :not_found | |||
| end | |||
| end | |||
| def create | |||
| @@ -20,17 +20,18 @@ class WikiPagesController < ApplicationController | |||
| diffs = Diff::LCS.sdiff(wiki_page_from.body, wiki_page_to.body) | |||
| diff_json = diffs.map { |change| | |||
| case change.action | |||
| when ?= | |||
| { type: 'context', content: change.old_element } | |||
| when ?| | |||
| [{ type: 'removed', content: change.old_element }, | |||
| { type: 'added', content: change.new_element }] | |||
| when ?+ | |||
| { type: 'added', content: change.new_element } | |||
| when ?- | |||
| { type: 'removed', content: change.old_element } | |||
| end }.flatten.compact | |||
| case change.action | |||
| when ?= | |||
| { type: 'context', content: change.old_element } | |||
| when ?| | |||
| [{ type: 'removed', content: change.old_element }, | |||
| { type: 'added', content: change.new_element }] | |||
| when ?+ | |||
| { type: 'added', content: change.new_element } | |||
| when ?- | |||
| { type: 'removed', content: change.old_element } | |||
| end | |||
| }.flatten.compact | |||
| render json: { wiki_page_id: wiki_page_from.id, | |||
| title: wiki_page_from.title, | |||
| @@ -54,14 +55,19 @@ class WikiPagesController < ApplicationController | |||
| def update | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless ['admin', 'member'].include?(current_user.role) | |||
| return head :forbidden unless current_user.member? | |||
| wiki_page = WikiPage.find(params[:id]) | |||
| return head :not_found unless wiki_page | |||
| title = params[:title] | |||
| body = params[:body] | |||
| return head :unprocessable_entity if title.blank? || body.blank? | |||
| wiki_page = WikiPage.find(params[:id]) | |||
| wiki_page.title = title | |||
| wiki_page.updated_user = current_user | |||
| wiki_page.set_body params[:body], user: current_user | |||
| wiki_page.set_body(body, user: current_user) | |||
| wiki_page.save! | |||
| head :ok | |||
| end | |||
| @@ -71,28 +77,34 @@ class WikiPagesController < ApplicationController | |||
| q = WikiPage.all | |||
| q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") if title.present? | |||
| render json: q.limit(20).map { |page| | |||
| page.sha = nil | |||
| page } | |||
| render json: q.limit(20) | |||
| end | |||
| def changes | |||
| id = params[:id] | |||
| log = id.present? ? wiki.page("#{ id }.md")&.versions : wiki.repo.log('main', nil, max_count: 50) | |||
| log = if id.present? | |||
| wiki.page("#{ id }.md")&.versions | |||
| else | |||
| wiki.repo.log('main', nil) | |||
| end | |||
| return render json: [] unless log | |||
| render json: log.map { |commit| | |||
| wiki_page = WikiPage.find(commit.message.split(' ')[1].to_i) | |||
| wiki_page.sha = commit.id | |||
| user = User.find(commit.author.name.to_i) | |||
| { sha: wiki_page.sha, | |||
| pred: wiki_page.pred, | |||
| succ: wiki_page.succ, | |||
| wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title }, | |||
| user: user && { id: user.id, name: user.name }, | |||
| change_type: commit.message.split(' ')[0].downcase[0...(-1)], | |||
| timestamp: commit.authored_date } } | |||
| wiki_page = WikiPage.find(commit.message.split(' ')[1].to_i) | |||
| wiki_page.sha = commit.id | |||
| next nil if wiki_page.sha.blank? | |||
| user = User.find(commit.author.name.to_i) | |||
| { sha: wiki_page.sha, | |||
| pred: wiki_page.pred, | |||
| succ: wiki_page.succ, | |||
| wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title }, | |||
| user: user && { id: user.id, name: user.name }, | |||
| change_type: commit.message.split(' ')[0].downcase[0...(-1)], | |||
| timestamp: commit.authored_date } | |||
| }.compact | |||
| end | |||
| private | |||
| @@ -1,6 +1,6 @@ | |||
| class PostTag < ApplicationRecord | |||
| belongs_to :post | |||
| belongs_to :tag | |||
| belongs_to :tag, counter_cache: :post_count | |||
| validates :post_id, presence: true | |||
| validates :tag_id, presence: true | |||
| @@ -19,4 +19,12 @@ class User < ApplicationRecord | |||
| def viewed? post | |||
| user_post_views.exists? post_id: post.id | |||
| end | |||
| def member? | |||
| ['member', 'admin'].include?(role) | |||
| end | |||
| def admin? | |||
| role == 'admin' | |||
| end | |||
| end | |||
| @@ -8,6 +8,11 @@ class WikiPage < ApplicationRecord | |||
| validates :title, presence: true, length: { maximum: 255 }, uniqueness: true | |||
| def as_json options = { } | |||
| self.sha = nil | |||
| super options | |||
| end | |||
| def sha= val | |||
| if val.present? | |||
| @sha = val | |||
| @@ -18,9 +23,16 @@ class WikiPage < ApplicationRecord | |||
| end | |||
| vers = @page.versions | |||
| idx = vers.find_index { |ver| ver.id == @sha } | |||
| @pred = vers[idx + 1]&.id | |||
| @succ = idx.positive? ? vers[idx - 1].id : nil | |||
| @updated_at = vers[idx].authored_date | |||
| if idx | |||
| @pred = vers[idx + 1]&.id | |||
| @succ = idx.positive? ? vers[idx - 1].id : nil | |||
| @updated_at = vers[idx].authored_date | |||
| else | |||
| @sha = nil | |||
| @pred = nil | |||
| @succ = nil | |||
| @updated_at = nil | |||
| end | |||
| @sha | |||
| end | |||
| @@ -1,5 +1,6 @@ | |||
| Rails.application.routes.draw do | |||
| get 'tags/autocomplete', to: 'tags#autocomplete' | |||
| get 'tags/name/:name', to: 'tags#show_by_name' | |||
| get 'posts/random', to: 'posts#random' | |||
| post 'posts/:id/viewed', to: 'posts#viewed' | |||
| delete 'posts/:id/viewed', to: 'posts#unviewed' | |||
| @@ -0,0 +1,5 @@ | |||
| class AddPostsCountToTags < ActiveRecord::Migration[8.0] | |||
| def change | |||
| add_column :tags, :post_count, :integer, null: false, default: 0 | |||
| end | |||
| end | |||
| @@ -1,4 +1,5 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import React, { useEffect, useState } from 'react' | |||
| import { Link, useNavigate, useParams } from 'react-router-dom' | |||
| @@ -18,11 +19,9 @@ export default ({ posts }: Props) => { | |||
| const navigate = useNavigate () | |||
| const [tags, setTags] = useState<TagByCategory> ({ }) | |||
| const [tagsCounts, setTagsCounts] = useState<{ [key: number]: number }> ({ }) | |||
| useEffect (() => { | |||
| const tagsTmp: TagByCategory = { } | |||
| const tagsCountsTmp: { [key: number]: number } = { } | |||
| for (const post of posts) | |||
| { | |||
| for (const tag of post.tags) | |||
| @@ -31,15 +30,11 @@ export default ({ posts }: Props) => { | |||
| tagsTmp[tag.category] = [] | |||
| if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id))) | |||
| tagsTmp[tag.category].push (tag) | |||
| if (!(tag.id in tagsCountsTmp)) | |||
| tagsCountsTmp[tag.id] = 0 | |||
| ++tagsCountsTmp[tag.id] | |||
| } | |||
| } | |||
| for (const cat of Object.keys (tagsTmp)) | |||
| tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) | |||
| setTags (tagsTmp) | |||
| setTagsCounts (tagsCountsTmp) | |||
| }, [posts]) | |||
| return ( | |||
| @@ -54,7 +49,7 @@ export default ({ posts }: Props) => { | |||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}> | |||
| {tag.name} | |||
| </Link> | |||
| <span className="ml-1">{tagsCounts[tag.id]}</span> | |||
| <span className="ml-1">{tag.postCount}</span> | |||
| </li>))} | |||
| </>))} | |||
| </ul> | |||
| @@ -1,11 +1,15 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import React, { useState, useEffect } from 'react' | |||
| import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' | |||
| import SettingsDialogue from './SettingsDialogue' | |||
| import { Button } from './ui/button' | |||
| import { cn } from '@/lib/utils' | |||
| import SettingsDialogue from '@/components/SettingsDialogue' | |||
| import { Button } from '@/components/ui/button' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | |||
| import { cn } from '@/lib/utils' | |||
| import type { User } from '@/types' | |||
| import type { Tag, User, WikiPage } from '@/types' | |||
| type Props = { user: User | |||
| setUser: (user: User) => void } | |||
| @@ -23,13 +27,14 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| const [settingsVsbl, setSettingsVsbl] = useState (false) | |||
| const [selectedMenu, setSelectedMenu] = useState<Menu> (Menu.None) | |||
| const [wikiId, setWikiId] = useState (WikiIdBus.get ()) | |||
| const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) | |||
| const [wikiSearch, setWikiSearch] = useState ('') | |||
| const [activeIndex, setActiveIndex] = useState (-1) | |||
| const [suggestions, setSuggestions] = useState<WikiPage[]> ([]) | |||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
| const [tagSearch, setTagSearch] = useState ('') | |||
| const [userSearch, setUserSearch] = useState ('') | |||
| const [postCount, setPostCount] = useState<number | null> (null) | |||
| const MyLink = ({ to, title, menu, base }: { to: string | |||
| title: string | |||
| @@ -93,7 +98,8 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| } | |||
| useEffect (() => { | |||
| WikiIdBus.subscribe (setWikiId) | |||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | |||
| return () => unsubscribe () | |||
| }, []) | |||
| useEffect (() => { | |||
| @@ -109,6 +115,28 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| setSelectedMenu (Menu.None) | |||
| }, [location]) | |||
| useEffect (() => { | |||
| if (!(wikiId)) | |||
| return | |||
| void ((async () => { | |||
| try | |||
| { | |||
| const { data: pageData } = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | |||
| const wikiPage: WikiPage = toCamel (pageData, { deep: true }) | |||
| const { data: tagData } = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | |||
| const tag: Tag = toCamel (tagData, { deep: true }) | |||
| setPostCount (tag.postCount) | |||
| } | |||
| catch | |||
| { | |||
| setPostCount (0) | |||
| } | |||
| }) ()) | |||
| }, [wikiId]) | |||
| return ( | |||
| <> | |||
| <nav className="bg-gray-800 text-white px-3 flex justify-between items-center w-full min-h-[48px]"> | |||
| @@ -173,10 +201,10 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| <Link to="/wiki/new" className={subClass}>新規</Link> | |||
| <Link to="/wiki/changes" className={subClass}>全体履歴</Link> | |||
| <Link to="/wiki/ヘルプ:Wiki" className={subClass}>ヘルプ</Link> | |||
| {/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && | |||
| {(/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId) && | |||
| <> | |||
| <Separator /> | |||
| <Link to={`/posts?tags=${ location.pathname.split ('/')[2] }`} className={subClass}>広場</Link> | |||
| <Link to={`/posts?tags=${ location.pathname.split ('/')[2] }`} className={subClass}>広場 ({postCount || 0})</Link> | |||
| <Link to={`/wiki/changes?id=${ wikiId }`} className={subClass}>履歴</Link> | |||
| <Link to={`/wiki/${ wikiId || location.pathname.split ('/')[2] }/edit`} className={subClass}>編輯</Link> | |||
| </>} | |||
| @@ -30,7 +30,7 @@ export default () => { | |||
| .then (res => setPosts (toCamel (res.data, { deep: true }))) | |||
| .catch (err => { | |||
| console.error ('Failed to fetch posts:', err) | |||
| setPosts (null) | |||
| setPosts ([]) | |||
| })) | |||
| setWikiPage (null) | |||
| @@ -72,7 +72,7 @@ export default () => { | |||
| : '広場には何もありませんよ.')} | |||
| </Tab> | |||
| {(wikiPage && wikiPage.body) && ( | |||
| <Tab name="Wiki" init={!(posts?.length)}> | |||
| <Tab name="Wiki" init={posts && !(posts.length)}> | |||
| <WikiBody body={wikiPage.body} /> | |||
| <div className="my-2"> | |||
| <Link to={`/wiki/${ wikiPage.title }`}>Wiki を見る</Link> | |||
| @@ -38,6 +38,8 @@ export default () => { | |||
| WikiIdBus.set (res.data.id) | |||
| }) | |||
| .catch (() => setWikiPage (null))) | |||
| return () => WikiIdBus.set (null) | |||
| }, [title, location.search]) | |||
| return ( | |||
| @@ -12,10 +12,10 @@ export type Post = { | |||
| viewed: boolean } | |||
| export type Tag = { | |||
| id: number | |||
| name: string | |||
| category: Category | |||
| count?: number} | |||
| id: number | |||
| name: string | |||
| category: Category | |||
| postCount: number } | |||
| export type User = { | |||
| id: number | |||