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