| @@ -126,7 +126,7 @@ class WikiPagesController < ApplicationController | |||||
| message:, | message:, | ||||
| base_revision_id:) | base_revision_id:) | ||||
| render json: WikiPageRepr.base(page).merge(body:), status: :created | |||||
| render json: WikiPageRepr.base(page).merge(body:) | |||||
| end | end | ||||
| def search | def search | ||||
| @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown' | |||||
| import remarkGFM from 'remark-gfm' | import remarkGFM from 'remark-gfm' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import WikiMarkdown from '@/components/WikiMarkdown' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import { wikiKeys } from '@/lib/queryKeys' | import { wikiKeys } from '@/lib/queryKeys' | ||||
| @@ -16,33 +17,6 @@ import type { Components } from 'react-markdown' | |||||
| type Props = { title: string | type Props = { title: string | ||||
| body?: string } | body?: string } | ||||
| const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||||
| a: (({ href, children }) => ( | |||||
| ['/', '.'].some (e => href?.startsWith (e)) | |||||
| ? <PrefetchLink to={href!}>{children}</PrefetchLink> | |||||
| : ( | |||||
| <a href={href} | |||||
| target="_blank" | |||||
| rel="noopener noreferrer"> | |||||
| {children} | |||||
| </a>))) } as const satisfies Components | |||||
| export default (({ title, body }: Props) => { | |||||
| const { data } = useQuery ({ | |||||
| enabled: Boolean (body), | |||||
| queryKey: wikiKeys.index ({ }), | |||||
| queryFn: () => fetchWikiPages ({ }) }) | |||||
| const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length) | |||||
| const remarkPlugins = useMemo ( | |||||
| () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) | |||||
| return ( | |||||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
| </ReactMarkdown>) | |||||
| }) satisfies FC<Props> | |||||
| export default (({ title, body }: Props) => | |||||
| <WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props> | |||||
| @@ -12,10 +12,12 @@ type Props = { | |||||
| title: string | title: string | ||||
| body: string | body: string | ||||
| onSubmit: (title: string, body: string) => void | onSubmit: (title: string, body: string) => void | ||||
| forEdit?: boolean } | |||||
| id?: number | null } | |||||
| export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) => { | |||||
| export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { | |||||
| const forEdit = id != null | |||||
| const [title, setTitle] = useState<string> (initTitle) | const [title, setTitle] = useState<string> (initTitle) | ||||
| const [body, setBody] = useState<string> (initBody) | const [body, setBody] = useState<string> (initBody) | ||||
| @@ -24,6 +26,21 @@ export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) | |||||
| setBody (initBody) | setBody (initBody) | ||||
| }, [initTitle, initBody]) | }, [initTitle, initBody]) | ||||
| const handleImageUpload = async (file: File) => { | |||||
| if (!(forEdit)) | |||||
| throw new Error ('画像は Wiki 作成前に追加することができません.') | |||||
| const formData = new FormData | |||||
| formData.append ('file', file) | |||||
| const asset = await apiPost<WikiAsset> ( | |||||
| `/wiki/${ id }/assets`, | |||||
| formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| return `{{img:${ asset.no }}}` | |||||
| } | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| {/* タイトル */} | {/* タイトル */} | ||||
| @@ -44,7 +61,8 @@ export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) | |||||
| value={body} | value={body} | ||||
| style={{ height: '500px' }} | style={{ height: '500px' }} | ||||
| renderHTML={text => mdParser.render (text)} | renderHTML={text => mdParser.render (text)} | ||||
| onChange={({ text }) => setBody (text)}/> | |||||
| onChange={({ text }) => setBody (text)} | |||||
| onImageUpload={handleImageUpload}/> | |||||
| </div> | </div> | ||||
| {/* 送信 */} | {/* 送信 */} | ||||
| @@ -0,0 +1,77 @@ | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { useMemo } from 'react' | |||||
| import ReactMarkdown from 'react-markdown' | |||||
| import remarkGFM from 'remark-gfm' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | |||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||||
| import { wikiKeys } from '@/lib/queryKeys' | |||||
| import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' | |||||
| import { fetchWikiPages } from '@/lib/wiki' | |||||
| import type { FC } from 'react' | |||||
| import type { Components } from 'react-markdown' | |||||
| type Props = { | |||||
| title?: string | |||||
| body: string | |||||
| preview?: boolean } | |||||
| const makeComponents = (preview = false) => ( | |||||
| { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||||
| a: ({ href, children }) => { | |||||
| if (!(href)) | |||||
| return <>{children}</> | |||||
| if (!(preview) && ['/', '.'].some (e => href.startsWith (e))) | |||||
| return <PrefetchLink to={href}>{children}</PrefetchLink> | |||||
| const ext = /^(?:https?:)?\/\//.test (href) | |||||
| return ( | |||||
| <a href={href} | |||||
| target={ext ? '_blank' : undefined} | |||||
| rel={ext ? 'noopener noreferrer' : undefined}> | |||||
| {children} | |||||
| </a>) | |||||
| }, | |||||
| img: (({ src, alt }) => ( | |||||
| <img src={src ?? ''} | |||||
| alt={alt ?? ''} | |||||
| className="max-w-full h-auto rounded"/>)), | |||||
| } as const satisfies Components) | |||||
| export default (({ title, body, preview = false }: Props) => { | |||||
| const { data } = useQuery ({ | |||||
| queryKey: wikiKeys.index ({ }), | |||||
| queryFn: () => fetchWikiPages ({ }) }) | |||||
| const pageNames = useMemo ( | |||||
| () => (data ?? []).map ((page) => page.title).sort ((a, b) => b.length - a.length), | |||||
| [data]) | |||||
| const remarkPlugins = useMemo ( | |||||
| () => [() => remarkWikiAutoLink (pageNames), remarkGFM], | |||||
| [pageNames]) | |||||
| const components = useMemo ( | |||||
| () => makeComponents (preview), | |||||
| [preview]) | |||||
| return ( | |||||
| <ReactMarkdown | |||||
| components={components} | |||||
| remarkPlugins={remarkPlugins}> | |||||
| {body | |||||
| || (title | |||||
| ? ('このページは存在しません。' | |||||
| +`[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`) | |||||
| : '')} | |||||
| </ReactMarkdown>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -43,8 +43,6 @@ export default (({ user }: Props) => { | |||||
| { | { | ||||
| await apiPut (`/wiki/${ id }`, formData, | await apiPut (`/wiki/${ id }`, formData, | ||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | { headers: { 'Content-Type': 'multipart/form-data' } }) | ||||
| qc.setQueryData (wikiKeys.show (title, { }), | |||||
| (prev: WikiPage) => ({ ...prev, title, body })) | |||||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | qc.invalidateQueries ({ queryKey: wikiKeys.root }) | ||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ title }`) | navigate (`/wiki/${ title }`) | ||||
| @@ -78,7 +76,7 @@ export default (({ user }: Props) => { | |||||
| title={title} | title={title} | ||||
| body={body} | body={body} | ||||
| onSubmit={handleSubmit} | onSubmit={handleSubmit} | ||||
| forEdit/>)} | |||||
| id={Number (id)}/>)} | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -38,8 +38,6 @@ export default ({ user }: Props) => { | |||||
| { | { | ||||
| const data = await apiPost<WikiPage> ('/wiki', formData, | const data = await apiPost<WikiPage> ('/wiki', formData, | ||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | { headers: { 'Content-Type': 'multipart/form-data' } }) | ||||
| qc.setQueryData (wikiKeys.show (data.title, { }), | |||||
| (prev: WikiPage) => ({ ...prev, title: data.title, body: data.body })) | |||||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | qc.invalidateQueries ({ queryKey: wikiKeys.root }) | ||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ data.title }`) | navigate (`/wiki/${ data.title }`) | ||||