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