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