diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 522b0d7..2367731 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }: { }/> }/> }/> + }/> + }/> }/> }/> }/> diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index d62fa0c..858bba4 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -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 ('../../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 => { +): Promise => { 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 => { + const content = await getHelpEditionSource (identifier, language, edition) + return content == null ? null : { title: content.title, body: content.body } +} + + export const getHelpSearchEntries = async ( language: string, ): Promise => { @@ -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 => { + 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 => { + 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 => { + 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) } +} diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 46254ea..0b1ce8a 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -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 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)) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index a1d03d3..57bd99e 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 } diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts index b6f63c2..3ffe67d 100644 --- a/frontend/src/lib/wiki.ts +++ b/frontend/src/lib/wiki.ts @@ -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 } } diff --git a/frontend/src/mdx-components.tsx b/frontend/src/mdx-components.tsx index b49be36..3118d4f 100644 --- a/frontend/src/mdx-components.tsx +++ b/frontend/src/mdx-components.tsx @@ -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 ( + + {children} + ) + } + + return ( + + {children} + ) +} + + +export const useMDXComponents = (): MDXComponents => ({ a: MDXLink }) diff --git a/frontend/src/pages/help/HelpChangesPage.tsx b/frontend/src/pages/help/HelpChangesPage.tsx new file mode 100644 index 0000000..5e0cc88 --- /dev/null +++ b/frontend/src/pages/help/HelpChangesPage.tsx @@ -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 ( + + + {`ヘルプ 変更履歴 | ${ SITE_TITLE }`} + + 変更履歴 + + {isLoading + ?

読み込み中...

+ : (error + ?

{error.message}

+ : ( +
+ + + + + + + + + + + + {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 ( + + + + + + + ) + })} + +
差分タイトル版変更更新日時
+ {entry.pred != null && ( + + 差分 + )} + + + {entry.title} + + {entry.edition}{entry.pred == null ? '新規' : '更新'}{dateString (updatedAt, 'hour')}
+
))} +
) +} + + +export default HelpChangesPage diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx index 2b281f9..79c9d38 100644 --- a/frontend/src/pages/help/HelpDetailPage.tsx +++ b/frontend/src/pages/help/HelpDetailPage.tsx @@ -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 - 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 const Content = current?.content diff --git a/frontend/src/pages/help/HelpDiffPage.tsx b/frontend/src/pages/help/HelpDiffPage.tsx new file mode 100644 index 0000000..9469f63 --- /dev/null +++ b/frontend/src/pages/help/HelpDiffPage.tsx @@ -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 + + const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })` + const path = `/help/${ encodeURIComponent (identifier) }` + + return ( + + + {`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`} + + {title} + + {diff + ? ( + <> +
+ + {diff.from.title} ({from}) + + → + + {diff.to.title} ({to}) + +
+
+		
+		  {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 (
+			
+			  {markers[line.type]}{line.content.replace (/\r?\n$/, '')}
+			)
+		  })}
+		
+	      
+ ) + :
Loading...
} +
) +} + + +export default HelpDiffPage diff --git a/frontend/src/pages/help/HelpListPage.tsx b/frontend/src/pages/help/HelpListPage.tsx index 60acb6d..8c24124 100644 --- a/frontend/src/pages/help/HelpListPage.tsx +++ b/frontend/src/pages/help/HelpListPage.tsx @@ -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 = { + title: 'asc', updated_at: 'desc' } const HelpSearchPage: FC = () => { - const [title, setTitle] = useState ('') - const [text, setText] = useState ('') - const [conditions, setConditions] = useState ({ title: '', text: '' }) - const [entries, setEntries] = useState ([]) - const [loading, setLoading] = useState (true) - const [error, setError] = useState (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 (() => ({ + 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) => { 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 ?

読み込み中...

: (error - ?

{error}

+ ?

{error.message}

: (

{results.length} 件

- - + + diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index 8aecd9e..1d696e9 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -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 + return (
タイトル最終更新 + + by="title" + label="タイトル" + currentOrder={order} + defaultDirection={defaultDirection}/> + + + by="updated_at" + label="最終更新" + currentOrder={order} + defaultDirection={defaultDirection}/> +