@@ -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,8 +1,8 @@
|
|||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
tags =
|
post_id = params[:post]
|
||||||
if params[:post].present?
|
tags = if post_id.present?
|
||||||
Tag.joins(:posts).where(posts: { id: params[:post] })
|
Tag.joins(:posts).where(posts: { id: post_id })
|
||||||
else
|
else
|
||||||
Tag.all
|
Tag.all
|
||||||
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)
|
.where('(category = ? AND name LIKE ?) OR name LIKE ?',
|
||||||
.select('tags.id, tags.name, tags.category, COUNT(posts.id) AS post_count')
|
|
||||||
.where('(tags.category = ? AND tags.name LIKE ?)
|
|
||||||
OR tags.name LIKE ?',
|
|
||||||
'nico', "nico:#{ q }%", "#{ q }%")
|
'nico', "nico:#{ q }%", "#{ q }%")
|
||||||
.group('tags.id')
|
.order('post_count DESC, name ASC')
|
||||||
.order('post_count DESC, tags.name ASC')
|
|
||||||
.limit(20))
|
.limit(20))
|
||||||
render json: tags.map { |tag| {
|
render json: tags
|
||||||
id: tag.id,
|
|
||||||
name: tag.name,
|
|
||||||
category: tag.category,
|
|
||||||
count: tag.post_count } }
|
|
||||||
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
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class WikiPagesController < ApplicationController
|
|||||||
{ type: 'added', content: change.new_element }
|
{ type: 'added', content: change.new_element }
|
||||||
when ?-
|
when ?-
|
||||||
{ type: 'removed', content: change.old_element }
|
{ type: 'removed', content: change.old_element }
|
||||||
end }.flatten.compact
|
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?
|
||||||
|
|
||||||
|
title = params[:title]
|
||||||
|
body = params[:body]
|
||||||
|
|
||||||
|
return head :unprocessable_entity if title.blank? || body.blank?
|
||||||
|
|
||||||
wiki_page = WikiPage.find(params[:id])
|
wiki_page = WikiPage.find(params[:id])
|
||||||
return head :not_found unless wiki_page
|
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|
|
render json: q.limit(20)
|
||||||
page.sha = nil
|
|
||||||
page }
|
|
||||||
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 = WikiPage.find(commit.message.split(' ')[1].to_i)
|
||||||
wiki_page.sha = commit.id
|
wiki_page.sha = commit.id
|
||||||
|
|
||||||
|
next nil if wiki_page.sha.blank?
|
||||||
|
|
||||||
user = User.find(commit.author.name.to_i)
|
user = User.find(commit.author.name.to_i)
|
||||||
|
|
||||||
{ sha: wiki_page.sha,
|
{ sha: wiki_page.sha,
|
||||||
pred: wiki_page.pred,
|
pred: wiki_page.pred,
|
||||||
succ: wiki_page.succ,
|
succ: wiki_page.succ,
|
||||||
|
|
||||||
wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title },
|
wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title },
|
||||||
user: user && { id: user.id, name: user.name },
|
user: user && { id: user.id, name: user.name },
|
||||||
change_type: commit.message.split(' ')[0].downcase[0...(-1)],
|
change_type: commit.message.split(' ')[0].downcase[0...(-1)],
|
||||||
timestamp: commit.authored_date } }
|
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 }
|
||||||
|
if idx
|
||||||
@pred = vers[idx + 1]&.id
|
@pred = vers[idx + 1]&.id
|
||||||
@succ = idx.positive? ? vers[idx - 1].id : nil
|
@succ = idx.positive? ? vers[idx - 1].id : nil
|
||||||
@updated_at = vers[idx].authored_date
|
@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 { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
|
||||||
|
|
||||||
import type { User } from '@/types'
|
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 { 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 (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type Tag = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
category: Category
|
category: Category
|
||||||
count?: number}
|
postCount: number }
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user