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