| @@ -28,6 +28,7 @@ class WikiPagesController < ApplicationController | |||
| def update | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless ['admin', 'member'].include?(current_user.role) | |||
| wiki_page = WikiPage.find(params[:id]) | |||
| return head :not_found unless wiki_page | |||
| @@ -37,4 +38,15 @@ class WikiPagesController < ApplicationController | |||
| wiki_page.save! | |||
| head :ok | |||
| 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 | |||
| @@ -5,6 +5,7 @@ Rails.application.routes.draw do | |||
| get 'preview/title', to: 'preview#title' | |||
| get 'preview/thumbnail', to: 'preview#thumbnail' | |||
| get 'wiki/title/:title', to: 'wiki_pages#show_by_title' | |||
| get 'wiki/search', to: 'wiki_pages#search' | |||
| get 'wiki/:id', to: 'wiki_pages#show' | |||
| post 'wiki', to: 'wiki_pages#create' | |||
| put 'wiki/:id', to: 'wiki_pages#update' | |||
| @@ -7,6 +7,7 @@ import TagDetailSidebar from '@/components/TagDetailSidebar' | |||
| import PostPage from '@/pages/PostPage' | |||
| import PostNewPage from '@/pages/PostNewPage' | |||
| import PostDetailPage from '@/pages/PostDetailPage' | |||
| import WikiPage from '@/pages/WikiPage' | |||
| import WikiNewPage from '@/pages/WikiNewPage' | |||
| import WikiEditPage from '@/pages/WikiEditPage' | |||
| import WikiDetailPage from '@/pages/WikiDetailPage' | |||
| @@ -61,6 +62,7 @@ export default () => { | |||
| <Route path="/posts/new" element={<PostNewPage />} /> | |||
| <Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | |||
| <Route path="/tags/:tag" element={<TagPage />} /> | |||
| <Route path="/wiki" element={<WikiPage />} /> | |||
| <Route path="/wiki/:name" element={<WikiDetailPage />} /> | |||
| <Route path="/wiki/new" element={<WikiNewPage />} /> | |||
| <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 TagSearch from './TagSearch' | |||
| 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 } | |||
| @@ -15,7 +16,7 @@ type Props = { post: Post | null } | |||
| export default ({ post }: Props) => { | |||
| const [tags, setTags] = useState<TagByCategory> ({ }) | |||
| const categoryNames: { [key: string]: string } = { | |||
| const categoryNames: { [key: Category]: string } = { | |||
| general: '一般', | |||
| deerjikist: 'ニジラー', | |||
| nico: 'ニコニコタグ' } | |||
| @@ -43,7 +44,7 @@ export default ({ post }: Props) => { | |||
| return ( | |||
| <SidebarComponent> | |||
| <TagSearch /> | |||
| {['general', 'deerjikist', 'nico'].map (cat => cat in tags && ( | |||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||
| <> | |||
| <h2>{categoryNames[cat]}</h2> | |||
| <ul> | |||
| @@ -8,8 +8,8 @@ import type { Tag } from '@/types' | |||
| const TagSearch: React.FC = () => { | |||
| const navigate = useNavigate () | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const [search, setSearch] = useState ('') | |||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
| @@ -1,5 +1,5 @@ | |||
| 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 { Button } from './ui/button' | |||
| import clsx from 'clsx' | |||
| @@ -19,10 +19,15 @@ const enum Menu { None, | |||
| const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const [settingsVisible, setSettingsVisible] = useState (false) | |||
| const [settingsVsbl, setSettingsVsbl] = useState (false) | |||
| const [selectedMenu, setSelectedMenu] = useState<Menu> (Menu.None) | |||
| 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 | |||
| title: string | |||
| @@ -35,7 +40,32 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| {title} | |||
| </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 (() => { | |||
| if (location.pathname.startsWith ('/posts')) | |||
| @@ -61,9 +91,9 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| <MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | |||
| </div> | |||
| <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} | |||
| setUser={setUser} /> | |||
| </div> | |||
| @@ -84,8 +114,14 @@ const TopNav: React.FC = ({ user, setUser }: Props) => { | |||
| case Menu.Wiki: | |||
| return ( | |||
| <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/new" 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) | |||
| WikiIdBus.set (res.data.id) | |||
| }) | |||
| .catch (() => setMarkdown (null))) | |||
| .catch (() => setMarkdown (''))) | |||
| }, [name]) | |||
| return ( | |||
| <MainArea> | |||
| <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> | |||
| </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 = { | |||
| id: number | |||
| url: string | |||
| @@ -9,11 +13,13 @@ export type Post = { | |||
| export type Tag = { | |||
| id: number | |||
| name: string | |||
| category: string | |||
| category: Category | |||
| count?: number} | |||
| export type User = { | |||
| id: number | |||
| name: string | null | |||
| inheritanceCode: string | |||
| role: string } | |||
| role: UserRole } | |||
| export type UserRole = typeof USER_ROLES[number] | |||