このコミットが含まれているのは:
@@ -38,6 +38,8 @@ import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
||||
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||
import HelpListPage from '@/pages/help/HelpListPage'
|
||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
@@ -98,6 +100,8 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="/help" element={<HelpListPage/>}/>
|
||||
<Route path="/help/changes" element={<HelpChangesPage/>}/>
|
||||
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
|
||||
+138
-4
@@ -18,6 +18,18 @@ export type HelpSearchEntry = HelpEditionText & {
|
||||
identifier: string
|
||||
edition: string }
|
||||
|
||||
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||
|
||||
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||
export type HelpChange = HelpSearchEntry & { pred: string | null }
|
||||
export type HelpDiffLine = {
|
||||
type: 'added' | 'removed' | 'unchanged'
|
||||
content: string }
|
||||
export type HelpDiff = {
|
||||
from: HelpEditionSource
|
||||
to: HelpEditionSource
|
||||
lines: HelpDiffLine[] }
|
||||
|
||||
const articleFiles = import.meta.glob<string> ('../../help/*/article.yml',
|
||||
{ query: '?raw',
|
||||
import: 'default',
|
||||
@@ -72,6 +84,18 @@ export const getHelpEditions = (
|
||||
}
|
||||
|
||||
|
||||
export const getHelpPublishedEditions = (
|
||||
identifier: string,
|
||||
language: string,
|
||||
): string[] => {
|
||||
const currentEdition = getHelpArticle (identifier)?.languages[language]?.currentEdition
|
||||
return currentEdition == null
|
||||
? []
|
||||
: getHelpEditions (identifier, language)
|
||||
.filter (edition => compareEditions (edition, currentEdition) <= 0)
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEdition = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
@@ -87,28 +111,40 @@ export const getHelpEdition = async (
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionText = async (
|
||||
export const getHelpEditionSource = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionText | null> => {
|
||||
): Promise<HelpEditionSource | null> => {
|
||||
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
|
||||
const loader = rawEditionFiles[path]
|
||||
|
||||
if (!(loader))
|
||||
return null
|
||||
|
||||
const mdx = (await loader ()).replace (/^\uFEFF/, '')
|
||||
const source = await loader ()
|
||||
const mdx = source.replace (/^\uFEFF/, '')
|
||||
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
|
||||
|
||||
if (!(heading))
|
||||
throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`)
|
||||
|
||||
return { title: heading[1].trim (),
|
||||
return { source,
|
||||
title: heading[1].trim (),
|
||||
body: mdx.slice (heading[0].length) }
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionText = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionText | null> => {
|
||||
const content = await getHelpEditionSource (identifier, language, edition)
|
||||
return content == null ? null : { title: content.title, body: content.body }
|
||||
}
|
||||
|
||||
|
||||
export const getHelpSearchEntries = async (
|
||||
language: string,
|
||||
): Promise<HelpSearchEntry[]> => {
|
||||
@@ -130,3 +166,101 @@ export const getHelpSearchEntries = async (
|
||||
|
||||
return entries.filter ((entry): entry is HelpSearchEntry => entry != null)
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionContent = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionContent | null> => {
|
||||
const [content, text] = await Promise.all ([
|
||||
getHelpEdition (identifier, language, edition),
|
||||
getHelpEditionText (identifier, language, edition)])
|
||||
|
||||
return content != null && text != null ? { content, ...text } : null
|
||||
}
|
||||
|
||||
|
||||
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
||||
const articles = Object.values (articleFiles).map (source =>
|
||||
cameliseArticle (parseYAML (source)))
|
||||
const changes = await Promise.all (articles.flatMap (article => {
|
||||
const editions = getHelpPublishedEditions (article.identifier, language)
|
||||
return editions.map (async (edition, index) => {
|
||||
const text = await getHelpEditionText (article.identifier, language, edition)
|
||||
if (text == null)
|
||||
throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`)
|
||||
|
||||
return { identifier: article.identifier,
|
||||
edition, pred: editions[index - 1] ?? null, ...text }
|
||||
})
|
||||
}))
|
||||
|
||||
return changes.sort ((a, b) =>
|
||||
compareEditions (b.edition, a.edition)
|
||||
|| a.identifier.localeCompare (b.identifier))
|
||||
}
|
||||
|
||||
|
||||
const diffHelpLines = (from: string, to: string): HelpDiffLine[] => {
|
||||
const before = from.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const after = to.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const lengths = Array.from ({ length: before.length + 1 },
|
||||
() => new Uint32Array (after.length + 1))
|
||||
|
||||
for (let i = before.length - 1; i >= 0; --i)
|
||||
{
|
||||
for (let j = after.length - 1; j >= 0; --j)
|
||||
{
|
||||
lengths[i][j] =
|
||||
before[i] === after[j]
|
||||
? lengths[i + 1][j + 1] + 1
|
||||
: Math.max (lengths[i + 1][j], lengths[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const lines: HelpDiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < before.length || j < after.length)
|
||||
{
|
||||
if (i < before.length && j < after.length && before[i] === after[j])
|
||||
{
|
||||
lines.push ({ type: 'unchanged', content: before[i] })
|
||||
++i
|
||||
++j
|
||||
}
|
||||
else if (i < before.length
|
||||
&& (j === after.length || lengths[i + 1][j] >= lengths[i][j + 1]))
|
||||
{
|
||||
lines.push ({ type: 'removed', content: before[i] })
|
||||
++i
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.push ({ type: 'added', content: after[j] })
|
||||
++j
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
export const getHelpDiff = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<HelpDiff | null> => {
|
||||
const editions = getHelpPublishedEditions (identifier, language)
|
||||
if (!(editions.includes (from)) || !(editions.includes (to)))
|
||||
return null
|
||||
|
||||
const [before, after] = await Promise.all ([
|
||||
getHelpEditionSource (identifier, language, from),
|
||||
getHelpEditionSource (identifier, language, to)])
|
||||
if (before == null || after == null)
|
||||
return null
|
||||
|
||||
return { from: before, to: after, lines: diffHelpLines (before.source, after.source) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
import { getHelpArticle,
|
||||
getHelpChanges,
|
||||
getHelpDiff,
|
||||
getHelpEditionContent,
|
||||
getHelpSearchEntries } from '@/lib/help'
|
||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
import { fetchWikiPage,
|
||||
fetchWikiPageByTitle,
|
||||
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
||||
const mPost = match<{ id: string }> ('/posts/:id')
|
||||
const mWiki = match<{ title: string }> ('/wiki/:title')
|
||||
const mTag = match<{ id: string }> ('/tags/:id')
|
||||
const mHelp = match<{ identifier: string }> ('/help/:identifier')
|
||||
const mHelpDiff = match<{ identifier: string }> ('/help/:identifier/diff')
|
||||
|
||||
|
||||
const boolFromQuery = (value: string | null): boolean =>
|
||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||
|
||||
|
||||
const prefetchHelpIndex: Prefetcher = async qc => {
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpShow: Prefetcher = async (qc, url) => {
|
||||
const matched = mHelp (url.pathname)
|
||||
if (!(matched))
|
||||
return
|
||||
|
||||
const { identifier } = matched.params
|
||||
const language = 'ja'
|
||||
const article = await qc.fetchQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
|
||||
if (edition == null)
|
||||
return
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.edition (identifier, language, edition),
|
||||
queryFn: () => getHelpEditionContent (identifier, language, edition),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpChanges: Prefetcher = async qc => {
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
|
||||
const matched = mHelpDiff (url.pathname)
|
||||
const fromValues = url.searchParams.getAll ('from')
|
||||
const toValues = url.searchParams.getAll ('to')
|
||||
const [from] = fromValues
|
||||
const [to] = toValues
|
||||
if (!(matched) || fromValues.length !== 1 || toValues.length !== 1 || !(from) || !(to))
|
||||
return
|
||||
|
||||
const { identifier } = matched.params
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
|
||||
@@ -223,6 +285,13 @@ const prefetchTagChanges: Prefetcher = async (qc, url) => {
|
||||
|
||||
|
||||
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
||||
{ test: u => /^\/help\/?$/.test (u.pathname), run: prefetchHelpIndex },
|
||||
{ test: u => /^\/help\/changes\/?$/.test (u.pathname), run: prefetchHelpChanges },
|
||||
{ test: u => Boolean (mHelpDiff (u.pathname)), run: prefetchHelpDiff },
|
||||
{ test: u => {
|
||||
const matched = mHelp (u.pathname)
|
||||
return Boolean (matched && matched.params.identifier !== 'changes')
|
||||
}, run: prefetchHelpShow },
|
||||
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
||||
run: prefetchPostsIndex },
|
||||
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
||||
|
||||
@@ -61,3 +61,13 @@ export const wikiKeys = {
|
||||
root: ['wiki'] as const,
|
||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
|
||||
|
||||
export const helpKeys = {
|
||||
root: ['help'] as const,
|
||||
index: (language: string) => ['help', 'index', language] as const,
|
||||
article: (identifier: string) => ['help', 'article', identifier] as const,
|
||||
edition: (identifier: string, language: string, edition: string) =>
|
||||
['help', 'edition', identifier, language, edition] as const,
|
||||
diff: (identifier: string, language: string, from: string, to: string) =>
|
||||
['help', 'diff', identifier, language, from, to] as const,
|
||||
changes: (language: string) => ['help', 'changes', language] as const }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { WikiPage } from '@/types'
|
||||
@@ -21,8 +23,10 @@ export const fetchWikiPageByTitle = async (
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
return null
|
||||
if (isAxiosError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
|
||||
export const useMDXComponents = (): MDXComponents => ({ })
|
||||
const MDXLink = (
|
||||
{ href, children, ...props }: ComponentProps<'a'>,
|
||||
) => {
|
||||
const location = useLocation ()
|
||||
const url = useMemo (() => {
|
||||
try
|
||||
{
|
||||
const base = new URL (location.pathname + location.search, window.location.origin)
|
||||
return href ? new URL (href, base) : null
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}, [href, location.pathname, location.search])
|
||||
const internal = url != null
|
||||
&& url.origin === window.location.origin
|
||||
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
const anchor = href?.startsWith ('#')
|
||||
|| (url?.pathname === location.pathname
|
||||
&& url.search === location.search && Boolean (url.hash))
|
||||
const external = url != null && !(internal)
|
||||
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
|
||||
if (internal && !(anchor) && props.download == null)
|
||||
{
|
||||
return (
|
||||
<PrefetchLink to={url.pathname + url.search + url.hash} {...props}>
|
||||
{children}
|
||||
</PrefetchLink>)
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href}
|
||||
{...props}
|
||||
target={external ? props.target ?? '_blank' : props.target}
|
||||
rel={props.rel ?? 'noopener noreferrer'}>
|
||||
{children}
|
||||
</a>)
|
||||
}
|
||||
|
||||
|
||||
export const useMDXComponents = (): MDXComponents => ({ a: MDXLink })
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpChanges } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { dateString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpChangesPage: FC = () => {
|
||||
const { data: changes = [], isLoading, error } = useQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`ヘルプ 変更履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageTitle>変更履歴</PageTitle>
|
||||
|
||||
{isLoading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto w-full whitespace-nowrap border-collapse">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">差分</th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">版</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">更新日時</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map (entry => {
|
||||
const path = `/help/${ encodeURIComponent (entry.identifier) }`
|
||||
const diffQuery = new URLSearchParams ({
|
||||
from: entry.pred ?? '', to: entry.edition })
|
||||
const updatedAt = `${ entry.edition.split ('.')[0] }T00:00:00`
|
||||
|
||||
return (
|
||||
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
||||
className="even:bg-gray-100 text-gray-900
|
||||
dark:even:bg-gray-700 dark:text-gray-100">
|
||||
<td className="p-2">
|
||||
{entry.pred != null && (
|
||||
<PrefetchLink to={`${ path }/diff?${ diffQuery.toString () }`}>
|
||||
差分
|
||||
</PrefetchLink>)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`${ path }?edition=${ entry.edition }`}>
|
||||
{entry.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2">{entry.edition}</td>
|
||||
<td className="p-2">{entry.pred == null ? '新規' : '更新'}</td>
|
||||
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpChangesPage
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpArticle, getHelpEdition, getHelpEditionText, getHelpEditions } from '@/lib/help'
|
||||
import { getHelpArticle, getHelpEditionContent, getHelpEditions } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC, ComponentType } from 'react'
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpDetailPage: FC = () => {
|
||||
@@ -18,9 +20,10 @@ const HelpDetailPage: FC = () => {
|
||||
// Locale 対応が入るまでは ja 固定
|
||||
const language = 'ja'
|
||||
|
||||
const article = useMemo (
|
||||
() => getHelpArticle (identifier),
|
||||
[identifier])
|
||||
const { data: article, isLoading: articleLoading } = useQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const requestedEdition = query.get ('edition')
|
||||
@@ -39,51 +42,20 @@ const HelpDetailPage: FC = () => {
|
||||
? editions[index + 1]
|
||||
: undefined
|
||||
|
||||
const [loaded, setLoaded] = useState<{ identifier: string
|
||||
language: string
|
||||
edition: string
|
||||
title: string
|
||||
content: ComponentType | null } | null> (null)
|
||||
const { data: current, isSuccess, isError } = useQuery ({
|
||||
enabled: edition != null && editions.includes (edition),
|
||||
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
|
||||
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
|
||||
staleTime: Infinity })
|
||||
|
||||
useEffect (() => {
|
||||
if (!(edition))
|
||||
return
|
||||
|
||||
let active = true
|
||||
|
||||
void (async () => {
|
||||
const component = await getHelpEdition (identifier, language, edition)
|
||||
const text = await getHelpEditionText (identifier, language, edition)
|
||||
|
||||
if (active)
|
||||
{
|
||||
setLoaded ({ identifier,
|
||||
language,
|
||||
edition,
|
||||
title: text?.title ?? '',
|
||||
content: component })
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [identifier, language, edition])
|
||||
|
||||
if (!(article)
|
||||
|| !(languageMeta)
|
||||
|| !(edition)
|
||||
|| !(editions.includes (edition)))
|
||||
if (!articleLoading
|
||||
&& (article == null
|
||||
|| languageMeta == null
|
||||
|| edition == null
|
||||
|| !(editions.includes (edition))))
|
||||
return <NotFound/>
|
||||
|
||||
const current =
|
||||
(loaded?.identifier === identifier
|
||||
&& loaded?.language === language
|
||||
&& loaded?.edition === edition)
|
||||
? loaded
|
||||
: null
|
||||
|
||||
if (current && (!(current.content) || !(current.title)))
|
||||
if (isError || (isSuccess && current == null))
|
||||
return <NotFound/>
|
||||
|
||||
const Content = current?.content
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpDiff } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
const markers = { added: '+ ', removed: '- ', unchanged: ' ' }
|
||||
|
||||
|
||||
const HelpDiffPage: FC = () => {
|
||||
const { identifier = '' } = useParams ()
|
||||
const [searchParams] = useSearchParams ()
|
||||
const from = searchParams.get ('from') ?? ''
|
||||
const to = searchParams.get ('to') ?? ''
|
||||
const validParams = Boolean (identifier && from && to)
|
||||
&& searchParams.getAll ('from').length === 1
|
||||
&& searchParams.getAll ('to').length === 1
|
||||
const { data: diff, isLoading, isError } = useQuery ({
|
||||
enabled: validParams,
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
staleTime: Infinity })
|
||||
|
||||
if (!(validParams) || isError || (!(isLoading) && diff == null))
|
||||
return <NotFound/>
|
||||
|
||||
const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })`
|
||||
const path = `/help/${ encodeURIComponent (identifier) }`
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
|
||||
{diff
|
||||
? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (from) }`}>
|
||||
{diff.from.title} ({from})
|
||||
</PrefetchLink>
|
||||
<span>→</span>
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (to) }`}>
|
||||
{diff.to.title} ({to})
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
<pre className="mt-4 overflow-x-auto whitespace-pre">
|
||||
<code>
|
||||
{diff.lines.map ((line, index) => {
|
||||
const colour = cn (
|
||||
'block text-gray-900 dark:text-gray-100',
|
||||
line.type === 'added' && 'bg-green-200 dark:bg-green-800',
|
||||
line.type === 'added' && 'text-green-950 dark:text-green-50',
|
||||
line.type === 'removed' && 'bg-red-200 dark:bg-red-800',
|
||||
line.type === 'removed' && 'text-red-950 dark:text-red-50')
|
||||
|
||||
return (
|
||||
<span key={index} className={colour}>
|
||||
{markers[line.type]}{line.content.replace (/\r?\n$/, '')}
|
||||
</span>)
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</>)
|
||||
: <div>Loading...</div>}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpDiffPage
|
||||
@@ -1,56 +1,54 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpSearchEntries } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FormEvent, FC } from 'react'
|
||||
|
||||
import type { HelpSearchEntry } from '@/lib/help'
|
||||
|
||||
type SearchConditions = { title: string; text: string }
|
||||
type HelpOrderField = 'title' | 'updated_at'
|
||||
type HelpOrder = `${ HelpOrderField }:${ 'asc' | 'desc' }`
|
||||
|
||||
const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
|
||||
title: 'asc', updated_at: 'desc' }
|
||||
|
||||
|
||||
const HelpSearchPage: FC = () => {
|
||||
const [title, setTitle] = useState ('')
|
||||
const [text, setText] = useState ('')
|
||||
const [conditions, setConditions] = useState<SearchConditions> ({ title: '', text: '' })
|
||||
const [entries, setEntries] = useState<HelpSearchEntry[]> ([])
|
||||
const [loading, setLoading] = useState (true)
|
||||
const [error, setError] = useState<string | null> (null)
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
|
||||
const order: HelpOrder =
|
||||
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
|
||||
? requestedOrder as HelpOrder
|
||||
: 'updated_at:desc'
|
||||
const conditions = useMemo<SearchConditions> (() => ({
|
||||
title: searchParams.get ('title') ?? '',
|
||||
text: searchParams.get ('text') ?? '' }), [searchParams])
|
||||
const [title, setTitle] = useState (conditions.title)
|
||||
const [text, setText] = useState (conditions.text)
|
||||
const { data: entries = [], isLoading: loading, error } = useQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
if (active)
|
||||
setEntries (await getHelpSearchEntries ('ja'))
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
if (active)
|
||||
setError (err instanceof Error ? err.message : '取得に失敗しました.')
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (active)
|
||||
setLoading (false)
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
setTitle (conditions.title)
|
||||
setText (conditions.text)
|
||||
}, [conditions.title, conditions.text])
|
||||
|
||||
const results = useMemo (() => {
|
||||
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase ()
|
||||
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase ()
|
||||
const [field, direction] = order.split (':')
|
||||
|
||||
return (
|
||||
entries.filter (entry => (
|
||||
@@ -58,12 +56,30 @@ const HelpSearchPage: FC = () => {
|
||||
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery))
|
||||
&& (!(textQuery)
|
||||
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery))))
|
||||
.sort ((a, b) => a.title.localeCompare (b.title, 'ja')))
|
||||
}, [entries, conditions])
|
||||
.sort ((a, b) => {
|
||||
const [dateA, revisionA = '1'] = a.edition.split ('.')
|
||||
const [dateB, revisionB = '1'] = b.edition.split ('.')
|
||||
const comparison = field === 'title'
|
||||
? a.title.localeCompare (b.title, 'ja')
|
||||
: (dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB))
|
||||
|
||||
return (direction === 'asc' ? comparison : -comparison)
|
||||
|| a.identifier.localeCompare (b.identifier)
|
||||
}))
|
||||
}, [entries, conditions, order])
|
||||
|
||||
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault ()
|
||||
setConditions ({ title, text })
|
||||
const next = new URLSearchParams (searchParams)
|
||||
if (title)
|
||||
next.set ('title', title)
|
||||
else
|
||||
next.delete ('title')
|
||||
if (text)
|
||||
next.set ('text', text)
|
||||
else
|
||||
next.delete ('text')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -105,15 +121,27 @@ const HelpSearchPage: FC = () => {
|
||||
{loading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error}</p>
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
||||
<table className="table-auto w-full border-collapse">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">最終更新</th>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="title"
|
||||
label="タイトル"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="updated_at"
|
||||
label="最終更新"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -13,11 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||
import { getHelpChanges } from '@/lib/help'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { getHelpSearchEntries } from '@/lib/help'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchTagByName } from '@/lib/tags'
|
||||
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
@@ -28,28 +29,46 @@ const WikiDetailPage: FC = () => {
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
|
||||
void (async () => {
|
||||
const identifier =
|
||||
(await getHelpSearchEntries ('ja'))
|
||||
.find (entry => (entry.title.trim ().normalize ('NFKC').toLowerCase ()
|
||||
=== (title.trim ().normalize ('NFKC').toLowerCase ()
|
||||
.replace (/^ヘルプ:/, ''))))
|
||||
.identifier
|
||||
|
||||
navigate (`/help/${ identifier }`, { replace: true })
|
||||
}) ()
|
||||
const queryClient = useQueryClient ()
|
||||
|
||||
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const version = query.get ('version') || undefined
|
||||
|
||||
const { data: wikiPage, isLoading: loading } = useQuery ({
|
||||
const { data: wikiPage, isLoading: loading, isSuccess, isFetching } = useQuery ({
|
||||
enabled: Boolean (title) && !(/^\d+$/.test (title)),
|
||||
queryKey: wikiKeys.show (title, { version }),
|
||||
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
||||
|
||||
useEffect (() => {
|
||||
if (!(isSuccess) || isFetching || wikiPage != null)
|
||||
return
|
||||
|
||||
let active = true
|
||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
||||
|
||||
void queryClient.fetchQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity }).then (changes => {
|
||||
if (!(active))
|
||||
return
|
||||
|
||||
const identifiers = new Set (changes
|
||||
.filter (entry => entry.title.trim ().normalize ('NFKC').toLowerCase () === lookupTitle)
|
||||
.map (entry => entry.identifier))
|
||||
if (identifiers.size === 1)
|
||||
navigate (`/help/${ encodeURIComponent ([...identifiers][0]) }`, { replace: true })
|
||||
}).catch (() => {
|
||||
// A failed Help lookup leaves the original missing Wiki URL in place.
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isSuccess, isFetching, wikiPage, title, version, queryClient, navigate])
|
||||
|
||||
const effectiveTitle = wikiPage?.title ?? title
|
||||
const deprecated = wikiPage?.deprecatedAt != null
|
||||
|
||||
@@ -85,19 +104,28 @@ const WikiDetailPage: FC = () => {
|
||||
if (!(/^\d+$/.test (title)))
|
||||
return
|
||||
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const data = await fetchWikiPage (title, { })
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
if (active)
|
||||
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [title, navigate])
|
||||
|
||||
if (isSuccess && wikiPage == null)
|
||||
return <NotFound/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする