@@ -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 | ||||