みてるぞ 2 weeks ago
parent
commit
e20f7fcc17
13 changed files with 140 additions and 75 deletions
  1. +2
    -2
      backend/app/controllers/posts_controller.rb
  2. +20
    -18
      backend/app/controllers/tags_controller.rb
  3. +42
    -30
      backend/app/controllers/wiki_pages_controller.rb
  4. +1
    -1
      backend/app/models/post_tag.rb
  5. +8
    -0
      backend/app/models/user.rb
  6. +15
    -3
      backend/app/models/wiki_page.rb
  7. +1
    -0
      backend/config/routes.rb
  8. +5
    -0
      backend/db/migrate/20250629140234_add_posts_count_to_tags.rb
  9. +2
    -7
      frontend/src/components/TagSidebar.tsx
  10. +36
    -8
      frontend/src/components/TopNav.tsx
  11. +2
    -2
      frontend/src/pages/posts/PostListPage.tsx
  12. +2
    -0
      frontend/src/pages/wiki/WikiDetailPage.tsx
  13. +4
    -4
      frontend/src/types.ts

+ 2
- 2
backend/app/controllers/posts_controller.rb View File

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


+ 20
- 18
backend/app/controllers/tags_controller.rb View File

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


+ 42
- 30
backend/app/controllers/wiki_pages_controller.rb View File

@@ -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
- 1
backend/app/models/post_tag.rb View File

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


+ 8
- 0
backend/app/models/user.rb View File

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

+ 15
- 3
backend/app/models/wiki_page.rb View File

@@ -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
- 0
backend/config/routes.rb View File

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


+ 5
- 0
backend/db/migrate/20250629140234_add_posts_count_to_tags.rb View File

@@ -0,0 +1,5 @@
class AddPostsCountToTags < ActiveRecord::Migration[8.0]
def change
add_column :tags, :post_count, :integer, null: false, default: 0
end
end

+ 2
- 7
frontend/src/components/TagSidebar.tsx View File

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


+ 36
- 8
frontend/src/components/TopNav.tsx View File

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


+ 2
- 2
frontend/src/pages/posts/PostListPage.tsx View File

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


+ 2
- 0
frontend/src/pages/wiki/WikiDetailPage.tsx View File

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


+ 4
- 4
frontend/src/types.ts View File

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


Loading…
Cancel
Save