@@ -28,6 +28,7 @@ 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) | |||||
wiki_page = WikiPage.find(params[:id]) | wiki_page = WikiPage.find(params[:id]) | ||||
return head :not_found unless wiki_page | return head :not_found unless wiki_page | ||||
@@ -37,4 +38,15 @@ class WikiPagesController < ApplicationController | |||||
wiki_page.save! | wiki_page.save! | ||||
head :ok | head :ok | ||||
end | end | ||||
def search | |||||
q = WikiPage.all | |||||
if params[:title].present? | |||||
title = params[:title].to_s.strip | |||||
q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | |||||
end | |||||
render json: q.limit(20) | |||||
end | |||||
end | end |
@@ -5,6 +5,7 @@ Rails.application.routes.draw do | |||||
get 'preview/title', to: 'preview#title' | get 'preview/title', to: 'preview#title' | ||||
get 'preview/thumbnail', to: 'preview#thumbnail' | get 'preview/thumbnail', to: 'preview#thumbnail' | ||||
get 'wiki/title/:title', to: 'wiki_pages#show_by_title' | get 'wiki/title/:title', to: 'wiki_pages#show_by_title' | ||||
get 'wiki/search', to: 'wiki_pages#search' | |||||
get 'wiki/:id', to: 'wiki_pages#show' | get 'wiki/:id', to: 'wiki_pages#show' | ||||
post 'wiki', to: 'wiki_pages#create' | post 'wiki', to: 'wiki_pages#create' | ||||
put 'wiki/:id', to: 'wiki_pages#update' | put 'wiki/:id', to: 'wiki_pages#update' | ||||
@@ -7,6 +7,7 @@ import TagDetailSidebar from '@/components/TagDetailSidebar' | |||||
import PostPage from '@/pages/PostPage' | import PostPage from '@/pages/PostPage' | ||||
import PostNewPage from '@/pages/PostNewPage' | import PostNewPage from '@/pages/PostNewPage' | ||||
import PostDetailPage from '@/pages/PostDetailPage' | import PostDetailPage from '@/pages/PostDetailPage' | ||||
import WikiPage from '@/pages/WikiPage' | |||||
import WikiNewPage from '@/pages/WikiNewPage' | import WikiNewPage from '@/pages/WikiNewPage' | ||||
import WikiEditPage from '@/pages/WikiEditPage' | import WikiEditPage from '@/pages/WikiEditPage' | ||||
import WikiDetailPage from '@/pages/WikiDetailPage' | import WikiDetailPage from '@/pages/WikiDetailPage' | ||||
@@ -61,6 +62,7 @@ export default () => { | |||||
<Route path="/posts/new" element={<PostNewPage />} /> | <Route path="/posts/new" element={<PostNewPage />} /> | ||||
<Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | <Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | ||||
<Route path="/tags/:tag" element={<TagPage />} /> | <Route path="/tags/:tag" element={<TagPage />} /> | ||||
<Route path="/wiki" element={<WikiPage />} /> | |||||
<Route path="/wiki/:name" element={<WikiDetailPage />} /> | <Route path="/wiki/:name" element={<WikiDetailPage />} /> | ||||
<Route path="/wiki/new" element={<WikiNewPage />} /> | <Route path="/wiki/new" element={<WikiNewPage />} /> | ||||
<Route path="/wiki/:id/edit" element={<WikiEditPage />} /> | <Route path="/wiki/:id/edit" element={<WikiEditPage />} /> | ||||
@@ -4,10 +4,11 @@ import { Link, useParams } from 'react-router-dom' | |||||
import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
import TagSearch from './TagSearch' | import TagSearch from './TagSearch' | ||||
import SidebarComponent from './layout/SidebarComponent' | import SidebarComponent from './layout/SidebarComponent' | ||||
import { CATEGORIES } from '@/consts' | |||||
import type { Post, Tag } from '@/types' | |||||
import type { Category, Post, Tag } from '@/types' | |||||
type TagByCategory = { [key: string]: Tag[] } | |||||
type TagByCategory = { [key: Category]: Tag[] } | |||||
type Props = { post: Post | null } | type Props = { post: Post | null } | ||||
@@ -15,7 +16,7 @@ type Props = { post: Post | null } | |||||
export default ({ post }: Props) => { | export default ({ post }: Props) => { | ||||
const [tags, setTags] = useState<TagByCategory> ({ }) | const [tags, setTags] = useState<TagByCategory> ({ }) | ||||
const categoryNames: { [key: string]: string } = { | |||||
const categoryNames: { [key: Category]: string } = { | |||||
general: '一般', | general: '一般', | ||||
deerjikist: 'ニジラー', | deerjikist: 'ニジラー', | ||||
nico: 'ニコニコタグ' } | nico: 'ニコニコタグ' } | ||||
@@ -43,7 +44,7 @@ export default ({ post }: Props) => { | |||||
return ( | return ( | ||||
<SidebarComponent> | <SidebarComponent> | ||||
<TagSearch /> | <TagSearch /> | ||||
{['general', 'deerjikist', 'nico'].map (cat => cat in tags && ( | |||||
{CATEGORIES.map ((cat: Category) => cat in tags && ( | |||||
<> | <> | ||||
<h2>{categoryNames[cat]}</h2> | <h2>{categoryNames[cat]}</h2> | ||||
<ul> | <ul> | ||||
@@ -8,8 +8,8 @@ import type { Tag } from '@/types' | |||||
const TagSearch: React.FC = () => { | const TagSearch: React.FC = () => { | ||||
const navigate = useNavigate () | |||||
const location = useLocation () | const location = useLocation () | ||||
const navigate = useNavigate () | |||||
const [search, setSearch] = useState ('') | const [search, setSearch] = useState ('') | ||||
const [suggestions, setSuggestions] = useState<Tag[]> ([]) | const [suggestions, setSuggestions] = useState<Tag[]> ([]) | ||||
@@ -1,5 +1,5 @@ | |||||
import React, { useState, useEffect } from 'react' | import React, { useState, useEffect } from 'react' | ||||
import { Link, useLocation, useParams } from 'react-router-dom' | |||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' | |||||
import SettingsDialogue from './SettingsDialogue' | import SettingsDialogue from './SettingsDialogue' | ||||
import { Button } from './ui/button' | import { Button } from './ui/button' | ||||
import clsx from 'clsx' | import clsx from 'clsx' | ||||
@@ -19,10 +19,15 @@ const enum Menu { None, | |||||
const TopNav: React.FC = ({ user, setUser }: Props) => { | const TopNav: React.FC = ({ user, setUser }: Props) => { | ||||
const location = useLocation () | const location = useLocation () | ||||
const navigate = useNavigate () | |||||
const [settingsVisible, setSettingsVisible] = 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 (WikiIdBus.get ()) | ||||
const [wikiSearch, setWikiSearch] = useState ('') | |||||
const [activeIndex, setActiveIndex] = useState (-1) | |||||
const [suggestions, setSuggestions] = useState<WikiPage[]> ([]) | |||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||||
const MyLink = ({ to, title, menu, base }: { to: string | const MyLink = ({ to, title, menu, base }: { to: string | ||||
title: string | title: string | ||||
@@ -35,7 +40,32 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||||
{title} | {title} | ||||
</Link>) | </Link>) | ||||
useEffect (() => WikiIdBus.subscribe (setWikiId), []) | |||||
const whenWikiSearchChanged = e => { | |||||
setWikiSearch (e.target.value) | |||||
const q: string = e.target.value.split (' ').at (-1) | |||||
if (!(q)) | |||||
{ | |||||
setSuggestions ([]) | |||||
return | |||||
} | |||||
// void (axios.get(`${ API_BASE_URL }/`)) | |||||
} | |||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||||
if (e.key === 'Enter' && wikiSearch.length && (!(suggestionsVsbl) || activeIndex < 0)) | |||||
{ | |||||
navigate (`/wiki/${ encodeURIComponent (wikiSearch) }`) | |||||
setSuggestionsVsbl (false) | |||||
} | |||||
} | |||||
const handleTagSelect = (tag: Tag) => { | |||||
} | |||||
useEffect (() => { | |||||
WikiIdBus.subscribe (setWikiId) | |||||
}, []) | |||||
useEffect (() => { | useEffect (() => { | ||||
if (location.pathname.startsWith ('/posts')) | if (location.pathname.startsWith ('/posts')) | ||||
@@ -61,9 +91,9 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||||
<MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | <MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | ||||
</div> | </div> | ||||
<div className="ml-auto pr-4"> | <div className="ml-auto pr-4"> | ||||
<Button onClick={() => setSettingsVisible (true)}>{user?.name || '名もなきニジラー'}</Button> | |||||
<SettingsDialogue visible={settingsVisible} | |||||
onVisibleChange={setSettingsVisible} | |||||
<Button onClick={() => setSettingsVsbl (true)}>{user?.name || '名もなきニジラー'}</Button> | |||||
<SettingsDialogue visible={settingsVsbl} | |||||
onVisibleChange={setSettingsVsbl} | |||||
user={user} | user={user} | ||||
setUser={setUser} /> | setUser={setUser} /> | ||||
</div> | </div> | ||||
@@ -84,8 +114,14 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||||
case Menu.Wiki: | case Menu.Wiki: | ||||
return ( | return ( | ||||
<div className={className}> | <div className={className}> | ||||
<input className={inputBox} | |||||
placeholder="Wiki 検索" /> | |||||
<input type="text" | |||||
className={inputBox} | |||||
placeholder="Wiki 検索" | |||||
value={wikiSearch} | |||||
onChange={whenWikiSearchChanged} | |||||
onFocus={() => setSuggestionsVsbl (true)} | |||||
onBlur={() => setSuggestionsVsbl (false)} | |||||
onKeyDown={handleKeyDown} /> | |||||
<Link to="/wiki" className={subClass}>検索</Link> | <Link to="/wiki" className={subClass}>検索</Link> | ||||
<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> | ||||
@@ -0,0 +1,9 @@ | |||||
export const CATEGORIES = ['general', | |||||
'character', | |||||
'deerjikist', | |||||
'meme', | |||||
'material', | |||||
'nico', | |||||
'meta'] as const | |||||
export const USER_ROLES = ['admin', 'member', 'guest'] as const |
@@ -27,18 +27,21 @@ export default () => { | |||||
setMarkdown (res.data.body) | setMarkdown (res.data.body) | ||||
WikiIdBus.set (res.data.id) | WikiIdBus.set (res.data.id) | ||||
}) | }) | ||||
.catch (() => setMarkdown (null))) | |||||
.catch (() => setMarkdown (''))) | |||||
}, [name]) | }, [name]) | ||||
return ( | return ( | ||||
<MainArea> | <MainArea> | ||||
<div className="prose mx-auto p-4"> | <div className="prose mx-auto p-4"> | ||||
<ReactMarkdown components={{ a: ( | |||||
({ href, children }) => (['/', '.'].some (e => href?.startsWith (e)) | |||||
? <Link to={href!}>{children}</Link> | |||||
: <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>)) }}> | |||||
{markdown || `このページは存在しません。[新規作成してください](/wiki/new?title=${ name })。`} | |||||
</ReactMarkdown> | |||||
{markdown == null ? 'Loading...' : ( | |||||
<> | |||||
<ReactMarkdown components={{ a: ( | |||||
({ href, children }) => (['/', '.'].some (e => href?.startsWith (e)) | |||||
? <Link to={href!}>{children}</Link> | |||||
: <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>)) }}> | |||||
{markdown || `このページは存在しません。[新規作成してください](/wiki/new?title=${ name })。`} | |||||
</ReactMarkdown> | |||||
</>)} | |||||
</div> | </div> | ||||
</MainArea>) | </MainArea>) | ||||
} | } |
@@ -0,0 +1,88 @@ | |||||
import React, { useEffect, useState } from 'react' | |||||
import { Link } from 'react-router-dom' | |||||
import axios from 'axios' | |||||
import MainArea from '@/components/layout/MainArea' | |||||
import { API_BASE_URL } from '@/config' | |||||
import type { Category, WikiPage } from '@/types' | |||||
export default () => { | |||||
const [title, setTitle] = useState ('') | |||||
const [text, setText] = useState ('') | |||||
const [category, setCategory] = useState<Category | null> (null) | |||||
const [results, setResults] = useState<WikiPage[]> ([]) | |||||
const search = () => { | |||||
void (axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) | |||||
.then (res => setResults (res.data))) | |||||
} | |||||
const handleSearch = (e: React.FormEvent) => { | |||||
e.preventDefault () | |||||
search () | |||||
} | |||||
useEffect (() => { | |||||
search () | |||||
}, []) | |||||
return ( | |||||
<MainArea> | |||||
<div className="max-w-xl"> | |||||
<h2 className="text-xl mb-4">Wiki</h2> | |||||
<form onSubmit={handleSearch} className="space-y-2"> | |||||
{/* タイトル */} | |||||
<div> | |||||
<label>タイトル:</label><br /> | |||||
<input type="text" | |||||
value={title} | |||||
onChange={e => setTitle (e.target.value)} | |||||
className="border p-1 w-full" /> | |||||
</div> | |||||
{/* 内容 */} | |||||
<div> | |||||
<label>内容:</label><br /> | |||||
<input type="text" | |||||
value={text} | |||||
onChange={e => setText (e.target.value)} | |||||
className="border p-1 w-full" /> | |||||
</div> | |||||
{/* 検索 */} | |||||
<div className="py-3"> | |||||
<button type="submit" | |||||
className="bg-blue-500 text-white px-4 py-2 rounded"> | |||||
検索 | |||||
</button> | |||||
</div> | |||||
</form> | |||||
</div> | |||||
<div className="mt-4"> | |||||
<table className="table-auto w-full border-collapse"> | |||||
<thead> | |||||
<tr> | |||||
<th className="p-2 text-left">タイトル</th> | |||||
<th className="p-2 text-left">最終更新</th> | |||||
</tr> | |||||
</thead> | |||||
<tbody> | |||||
{results.map (page => ( | |||||
<tr key={page.id}> | |||||
<td className="p-2"> | |||||
<Link to={`/wiki/${ encodeURIComponent (page.title) }`} | |||||
className="text-blue-400 hover:underline"> | |||||
{page.title} | |||||
</Link> | |||||
</td> | |||||
<td className="p-2 text-gray-100 text-sm"> | |||||
{page.updated_at} | |||||
</td> | |||||
</tr>))} | |||||
</tbody> | |||||
</table> | |||||
</div> | |||||
</MainArea>) | |||||
} |
@@ -1,3 +1,7 @@ | |||||
import { CATEGORIES, USER_ROLES } from '@/consts' | |||||
export type Category = typeof CATEGORIES[number] | |||||
export type Post = { | export type Post = { | ||||
id: number | id: number | ||||
url: string | url: string | ||||
@@ -9,11 +13,13 @@ export type Post = { | |||||
export type Tag = { | export type Tag = { | ||||
id: number | id: number | ||||
name: string | name: string | ||||
category: string | |||||
category: Category | |||||
count?: number} | count?: number} | ||||
export type User = { | export type User = { | ||||
id: number | id: number | ||||
name: string | null | name: string | null | ||||
inheritanceCode: string | inheritanceCode: string | ||||
role: string } | |||||
role: UserRole } | |||||
export type UserRole = typeof USER_ROLES[number] |