このコミットが含まれているのは:
2026-10-12 14:46:19 +09:00
コミット 4813a3a09e
11個のファイルの変更、566行の追加、110行の削除
+4
ファイルの表示
@@ -38,6 +38,8 @@ import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage' 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 HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' 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/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/> <Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/> <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="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
+138 -4
ファイルの表示
@@ -18,6 +18,18 @@ export type HelpSearchEntry = HelpEditionText & {
identifier: string identifier: string
edition: 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', const articleFiles = import.meta.glob<string> ('../../help/*/article.yml',
{ query: '?raw', { query: '?raw',
import: 'default', 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 ( export const getHelpEdition = async (
identifier: string, identifier: string,
language: string, language: string,
@@ -87,28 +111,40 @@ export const getHelpEdition = async (
} }
export const getHelpEditionText = async ( export const getHelpEditionSource = async (
identifier: string, identifier: string,
language: string, language: string,
edition: string, edition: string,
): Promise<HelpEditionText | null> => { ): Promise<HelpEditionSource | null> => {
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx` const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
const loader = rawEditionFiles[path] const loader = rawEditionFiles[path]
if (!(loader)) if (!(loader))
return null 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|$)/) const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
if (!(heading)) if (!(heading))
throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`) throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`)
return { title: heading[1].trim (), return { source,
title: heading[1].trim (),
body: mdx.slice (heading[0].length) } 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 ( export const getHelpSearchEntries = async (
language: string, language: string,
): Promise<HelpSearchEntry[]> => { ): Promise<HelpSearchEntry[]> => {
@@ -130,3 +166,101 @@ export const getHelpSearchEntries = async (
return entries.filter ((entry): entry is HelpSearchEntry => entry != null) 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) }
}
+70 -1
ファイルの表示
@@ -1,8 +1,13 @@
import { QueryClient } from '@tanstack/react-query' import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp' import { match } from 'path-to-regexp'
import { getHelpArticle,
getHelpChanges,
getHelpDiff,
getHelpEditionContent,
getHelpSearchEntries } from '@/lib/help'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' 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 { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage, import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title') const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id') 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 => const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) ['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 prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const title = url.searchParams.get ('title') ?? '' 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 }[] = [ 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), { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex }, run: prefetchPostsIndex },
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname)) { test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
+10
ファイルの表示
@@ -61,3 +61,13 @@ export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, 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 }
+5 -1
ファイルの表示
@@ -1,3 +1,5 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
@@ -21,8 +23,10 @@ export const fetchWikiPageByTitle = async (
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
} }
catch catch (error)
{ {
if (isAxiosError (error) && error.response?.status === 404)
return null return null
throw error
} }
} }
+49 -1
ファイルの表示
@@ -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 { 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 })
+78
ファイルの表示
@@ -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
+20 -48
ファイルの表示
@@ -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 { Helmet } from 'react-helmet-async'
import { useLocation, useParams } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' 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 NotFound from '@/pages/NotFound'
import type { FC, ComponentType } from 'react' import type { FC } from 'react'
const HelpDetailPage: FC = () => { const HelpDetailPage: FC = () => {
@@ -18,9 +20,10 @@ const HelpDetailPage: FC = () => {
// Locale 対応が入るまでは ja 固定 // Locale 対応が入るまでは ja 固定
const language = 'ja' const language = 'ja'
const article = useMemo ( const { data: article, isLoading: articleLoading } = useQuery ({
() => getHelpArticle (identifier), queryKey: helpKeys.article (identifier),
[identifier]) queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const requestedEdition = query.get ('edition') const requestedEdition = query.get ('edition')
@@ -39,51 +42,20 @@ const HelpDetailPage: FC = () => {
? editions[index + 1] ? editions[index + 1]
: undefined : undefined
const [loaded, setLoaded] = useState<{ identifier: string const { data: current, isSuccess, isError } = useQuery ({
language: string enabled: edition != null && editions.includes (edition),
edition: string queryKey: helpKeys.edition (identifier, language, edition ?? ''),
title: string queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
content: ComponentType | null } | null> (null) staleTime: Infinity })
useEffect (() => { if (!articleLoading
if (!(edition)) && (article == null
return || languageMeta == null
|| edition == null
let active = true || !(editions.includes (edition))))
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)))
return <NotFound/> return <NotFound/>
const current = if (isError || (isSuccess && current == null))
(loaded?.identifier === identifier
&& loaded?.language === language
&& loaded?.edition === edition)
? loaded
: null
if (current && (!(current.content) || !(current.title)))
return <NotFound/> return <NotFound/>
const Content = current?.content const Content = current?.content
+81
ファイルの表示
@@ -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
+64 -36
ファイルの表示
@@ -1,56 +1,54 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { getHelpSearchEntries } from '@/lib/help' import { getHelpSearchEntries } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent, FC } from 'react' import type { FormEvent, FC } from 'react'
import type { HelpSearchEntry } from '@/lib/help'
type SearchConditions = { title: string; text: string } 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 HelpSearchPage: FC = () => {
const [title, setTitle] = useState ('') const [searchParams, setSearchParams] = useSearchParams ()
const [text, setText] = useState ('') const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
const [conditions, setConditions] = useState<SearchConditions> ({ title: '', text: '' }) const order: HelpOrder =
const [entries, setEntries] = useState<HelpSearchEntry[]> ([]) ['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
const [loading, setLoading] = useState (true) ? requestedOrder as HelpOrder
const [error, setError] = useState<string | null> (null) : '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 (() => { useEffect (() => {
let active = true setTitle (conditions.title)
setText (conditions.text)
void (async () => { }, [conditions.title, conditions.text])
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 }
}, [])
const results = useMemo (() => { const results = useMemo (() => {
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase () const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase ()
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase () const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase ()
const [field, direction] = order.split (':')
return ( return (
entries.filter (entry => ( entries.filter (entry => (
@@ -58,12 +56,30 @@ const HelpSearchPage: FC = () => {
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery)) || entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery))
&& (!(textQuery) && (!(textQuery)
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery)))) || entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery))))
.sort ((a, b) => a.title.localeCompare (b.title, 'ja'))) .sort ((a, b) => {
}, [entries, conditions]) 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>) => { const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault () 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 ( return (
@@ -105,15 +121,27 @@ const HelpSearchPage: FC = () => {
{loading {loading
? <p className="mt-4">読み込み中...</p> ? <p className="mt-4">読み込み中...</p>
: (error : (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"> <div className="mt-4">
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p> <p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
<table className="table-auto w-full border-collapse"> <table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
<tr> <tr>
<th className="p-2 text-left">タイトル</th> <th className="p-2 text-left">
<th className="p-2 text-left">最終更新</th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
+45 -17
ファイルの表示
@@ -1,6 +1,4 @@
import type { FC } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate, useParams } from 'react-router-dom' 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 MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { getHelpChanges } from '@/lib/help'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { getHelpSearchEntries } from '@/lib/help'
import { fetchTagByName } from '@/lib/tags' import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -28,28 +29,46 @@ const WikiDetailPage: FC = () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const queryClient = useQueryClient ()
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 defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title]) const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const version = query.get ('version') || undefined 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)), enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }), queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (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 effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null const deprecated = wikiPage?.deprecatedAt != null
@@ -85,10 +104,12 @@ const WikiDetailPage: FC = () => {
if (!(/^\d+$/.test (title))) if (!(/^\d+$/.test (title)))
return return
let active = true
void (async () => { void (async () => {
try try
{ {
const data = await fetchWikiPage (title, { }) const data = await fetchWikiPage (title, { })
if (active)
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true }) navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
} }
catch catch
@@ -96,8 +117,15 @@ const WikiDetailPage: FC = () => {
; ;
} }
}) () }) ()
return () => {
active = false
}
}, [title, navigate]) }, [title, navigate])
if (isSuccess && wikiPage == null)
return <NotFound/>
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>