From 88e691165a3dc5087e766606dd61169f652365f8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 12 Oct 2026 02:37:56 +0900 Subject: [PATCH] #424 --- .../app/controllers/wiki_pages_controller.rb | 86 ++--------- backend/lib/tasks/migrate_help_pages.rake | 13 +- frontend/src/App.tsx | 132 +++++++---------- frontend/src/components/TopNav.tsx | 3 + frontend/src/lib/help.ts | 127 +++++++++++----- frontend/src/pages/help/HelpDetailPage.tsx | 126 ++++++++++++++++ frontend/src/pages/help/HelpListPage.tsx | 137 ++++++++++++++++++ frontend/vite.config.ts | 16 +- 8 files changed, 449 insertions(+), 191 deletions(-) create mode 100644 frontend/src/pages/help/HelpListPage.tsx diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 929c550..c00d55c 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -8,7 +8,6 @@ class WikiPagesController < ApplicationController descriptions = TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) - pages = unmigrated_wiki_pages.includes(tag_name: :tag) tag_groups = TagGroup.all if title.present? @@ -19,12 +18,10 @@ class WikiPagesController < ApplicationController .where('tag_names.name LIKE ?', pattern).limit(20) tag_groups = tag_groups.where( 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) - pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) end json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) - json.concat(WikiPageRepr.base(pages)) json = json.first(20) if title.present? render json: end @@ -37,11 +34,7 @@ class WikiPagesController < ApplicationController tag_group = TagGroup.find_by(id: params[:id]) return render_tag_group tag_group if tag_group - page = WikiPage.joins(:tag_name) - .includes(tag_name: :tag) - .find_by(id: params[:id]) - - render_wiki_page_or_404 page + render_wiki_page_or_404 nil end def show_by_title @@ -58,20 +51,14 @@ class WikiPagesController < ApplicationController return render_wiki_page_by_tag_description tag_description if tag_description - page = - WikiPage - .joins(:tag_name) - .includes(tag_name: :tag) - .find_by(tag_name: { name: title }) - - render_wiki_page_or_404 page + render_wiki_page_or_404 nil end def exists return head :no_content if TagDescription.exists?(id: params[:id]) return head :no_content if TagGroup.exists?(id: params[:id]) - head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found + head :not_found end def exists_by_title @@ -82,8 +69,7 @@ class WikiPagesController < ApplicationController end return head :no_content if tag_description_by_title(title) - exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) - head exists ? :no_content : :not_found + head :not_found end def diff @@ -112,15 +98,7 @@ class WikiPagesController < ApplicationController 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'deprecated_at' => nil } else - page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) - from_rev = from && page.wiki_revisions.find(from) - to_rev = to ? page.wiki_revisions.find(to) : page.current_revision - if ((from_rev && !from_rev.content?) || !to_rev&.content?) - return render_unprocessable_entity('差分を表示できない版です.') - end - from_body = from_rev&.body - to_body = to_rev.body - resource = WikiPageRepr.base(page) + raise 'お前やりませんねぇスギ!' end end @@ -160,25 +138,17 @@ class WikiPagesController < ApplicationController status: :created) end - page = if title.include?(':') - ApplicationRecord.transaction do - tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| - name.script_code = 'Jpan' - name.primary_flg = true - name.auto_generated = false + page = + if title.include?(':') + raise 'お前やりませんねぇスギ!' + else + tag_name = TagName.find_by(language_code: 'ja', name: title) + return render_unprocessable_entity field: :title unless tag_name&.tag + + TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user: current_user, message:) end - Wiki::Commit.create_legacy_content!( - tag_name:, body:, created_by_user: current_user, message:) - end - else - tag_name = TagName.find_by(language_code: 'ja', name: title) - return render_unprocessable_entity field: :title unless tag_name&.tag - - TagDescriptionOperation.create!( - tag: tag_name.tag, body:, created_by_user: current_user, message:) - end - render json: WikiPageRepr.base(page), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record @@ -273,23 +243,7 @@ class WikiPagesController < ApplicationController return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) end - pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages - revisions = WikiRevision.joins(wiki_page: :tag_name) - .where(wiki_page_id: pages.select(:id)) - .includes(:created_user, wiki_page: { tag_name: :tag }) - .order(id: :desc).limit(200) - json = revisions.map { |rev| - { revision_id: rev.id, - pred: rev.base_revision_id, - succ: nil, - wiki_page: { id: rev.wiki_page_id, - title: rev.wiki_page.title, - deprecated_at: rev.wiki_page.deprecated_at }, - user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, - kind: rev.kind, - message: rev.message, - timestamp: rev.created_at } - } + json = [] unless id json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(description_changes(TagDescription.where(language_code: 'ja'))) @@ -453,16 +407,6 @@ class WikiPagesController < ApplicationController .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) end - def unmigrated_wiki_pages - descriptions = TagDescription.where(language_code: 'ja') - - WikiPage - .joins(:tag_name) - .where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%") - .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', - descriptions.select(:tag_id)) - end - def description_changes descriptions versions = TagDescriptionVersion.where(language_code: 'ja', tag_id: descriptions.select(:tag_id)) diff --git a/backend/lib/tasks/migrate_help_pages.rake b/backend/lib/tasks/migrate_help_pages.rake index ef9191a..c436091 100644 --- a/backend/lib/tasks/migrate_help_pages.rake +++ b/backend/lib/tasks/migrate_help_pages.rake @@ -2,15 +2,21 @@ namespace :help do desc 'ヘルプ移行' task migrate: :environment do help_path = ENV.fetch('BTRC_HUB_HELP_PATH') - abort if help_path.blank? + abort 'ヘルプ置場指定してね.' if help_path.blank? + + # ファイルがすでに存在する場合は通さなぃ. + abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present? # 現状物理削除はなぃはず pages = WikiPage .joins(:tag_name) - .where(tag_names: { tag_id: nil }) + .where(tag_names: { tag_id: nil, language_code: 'ja' }) .where.not("tag_names.name LIKE 'タググループ:%'") + puts "移行対象: #{pages.count} 件" + abort '移行対象がありません.' if pages.empty? + pages.find_each do |page| puts page.body puts page.title @@ -30,7 +36,8 @@ namespace :help do file_name += ".#{ (last_file_name_no || 1).to_i + 1 }" end - File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", ver.body + body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }" + File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body last_file_name = file_name end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6dae14..522b0d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAnimationMode, applyClientAppearance, - type ClientAnimationMode, fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, @@ -25,7 +24,6 @@ import { applyClientAnimationMode, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' - import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' @@ -34,12 +32,13 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage' import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' -// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' import MorePage from '@/pages/MorePage' import GekanatorPage from '@/pages/GekanatorPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import TOSPage from '@/pages/TOSPage.mdx' +import HelpDetailPage from '@/pages/help/HelpDetailPage' +import HelpListPage from '@/pages/help/HelpListPage' import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostNewPage from '@/pages/posts/PostNewPage' @@ -60,90 +59,60 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import type { Dispatch, FC, SetStateAction } from 'react' +import type { ClientAnimationMode } from '@/lib/settings' import type { User } from '@/types' const RouteTransitionWrapper = ({ animationMode, user, setUser }: { - animationMode: ClientAnimationMode - user: User | null - setUser: Dispatch> }) => { + animationMode: ClientAnimationMode + user: User | null + setUser: Dispatch> }) => { const location = useLocation () + const routes = ( + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> + }/> + }/> + }/> + }/> + }/> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + ) + if (animationMode === 'off') - { - return ( - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }> - }/> - }/> - }/> - }/> - }/> - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - ) - } + return routes return ( - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }> - }/> - }/> - }/> - }/> - }/> - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - + {routes} ) } @@ -166,9 +135,9 @@ const RoutedApp: FC = () => { () => ( animationMode === 'off' ? { duration: 0 } - : animationMode === 'reduced' - ? { duration: .08, ease: 'linear' as const } - : { duration: .2, ease: 'easeOut' as const } + : (animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const }) ), [animationMode], ) @@ -285,10 +254,12 @@ const RoutedApp: FC = () => { ) } + const router = createBrowserRouter ([{ path: '*', element: }]) + const App: FC = () => ( <> @@ -296,4 +267,5 @@ const App: FC = () => ( ) + export default App diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 755651a..dbc8bf8 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -82,6 +82,9 @@ export const menuOutline = ( { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg && editable }] }, + { name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [ + { name: '検索', to: '/help' }, + { name: '全体履歴', to: '/help/changes' }] }, { name: 'おたのしみ', visible: false, subMenu: [ { name: '上映会 (β)', to: '/theatres/1' }, { name: 'グカネータ (β)', to: '/gekanator' }] }, diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index 7c01271..d62fa0c 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -1,49 +1,61 @@ -import { parse } from 'yaml' +import { parse as parseYAML } from 'yaml' import type { ComponentType } from 'react' -type ArticleLanguage = { - title: string - currentEdition: string } +type ArticleLanguage = { currentEdition: string } type ArticleMeta = { - identifier: string - canonicalLanguage: string - languages: Record } + identifier: string + languages: Record } -type MdxModule = { default: ComponentType } +type MDXModule = { default: ComponentType } + +export type HelpEditionText = { + title: string + body: string } + +export type HelpSearchEntry = HelpEditionText & { + identifier: string + edition: string } + +const articleFiles = import.meta.glob ('../../help/*/article.yml', + { query: '?raw', + import: 'default', + eager: true }) + +const editionFiles = import.meta.glob ('../../help/*/editions/*/*.mdx') + +const rawEditionFiles = import.meta.glob ('../../help/*/editions/*/*.mdx', + { query: '?raw', + import: 'default' }) -const articleFiles = import.meta.glob ( - '../../help/*/article.yml', - { query: '?raw', import: 'default', eager: true, }) - -const editionFiles = import.meta.glob ('../../help/*/editions/*/*.mdx') - - -const cameliseArticle = (value: { - identifier: string - canonical_language: string - languages: Record }): ArticleMeta => ( - { identifier: value.identifier, - canonicalLanguage: value.canonical_language, +const cameliseArticle = ( + value: { identifier: string + languages: Record }, +): ArticleMeta => ( + { identifier: value.identifier, languages: Object.fromEntries ( Object.entries (value.languages).map (([language, data]) => [ - language, - { title: data.title, - currentEdition: data.current_edition, }])) }) + language, { currentEdition: data['current_edition'] }])) }) -export const getHelpArticle = (identifier: string): ArticleMeta | undefined => { - const path = `../../help/${identifier}/article.yml` +const compareEditions = (a: string, b: string): number => { + const [dateA, revisionA = '1'] = a.split ('.') + const [dateB, revisionB = '1'] = b.split ('.') + + return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB) +} + + +export const getHelpArticle = (identifier: string): ArticleMeta | null => { + const path = `../../help/${ identifier }/article.yml` const source = articleFiles[path] if (!(source)) - return undefined + return null - return cameliseArticle (parse (source)) + return cameliseArticle (parseYAML (source)) } @@ -51,12 +63,12 @@ export const getHelpEditions = ( identifier: string, language: string, ): string[] => { - const prefix = `../../help/${identifier}/editions/${language}/` + const prefix = `../../help/${ identifier }/editions/${ language }/` return (Object.keys (editionFiles) .filter (path => path.startsWith (prefix)) .map (path => path.slice (prefix.length, -'.mdx'.length)) - .sort ()) + .sort (compareEditions)) } @@ -64,12 +76,57 @@ export const getHelpEdition = async ( identifier: string, language: string, edition: string, -): Promise => { - const path = `../../help/${identifier}/editions/${language}/${edition}.mdx` +): Promise => { + const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx` const loader = editionFiles[path] if (!(loader)) - return undefined + return null return (await loader ()).default } + + +export const getHelpEditionText = async ( + identifier: string, + language: string, + edition: string, +): Promise => { + const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx` + const loader = rawEditionFiles[path] + + if (!(loader)) + return null + + const mdx = (await loader ()).replace (/^\uFEFF/, '') + const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/) + + if (!(heading)) + throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`) + + return { title: heading[1].trim (), + body: mdx.slice (heading[0].length) } +} + + +export const getHelpSearchEntries = async ( + language: string, +): Promise => { + const entries = await Promise.all ( + Object.values (articleFiles).map (async source => { + const article = cameliseArticle (parseYAML (source)) + const edition = article.languages[language]?.currentEdition + + if (!(edition)) + return null + + const content = await getHelpEditionText (article.identifier, language, edition) + + if (!(content)) + throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`) + + return { identifier: article.identifier, edition, ...content } + })) + + return entries.filter ((entry): entry is HelpSearchEntry => entry != null) +} diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx index e69de29..2b281f9 100644 --- a/frontend/src/pages/help/HelpDetailPage.tsx +++ b/frontend/src/pages/help/HelpDetailPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useState } 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 NotFound from '@/pages/NotFound' + +import type { FC, ComponentType } from 'react' + + +const HelpDetailPage: FC = () => { + const { identifier = '' } = useParams () + const location = useLocation () + + // Locale 対応が入るまでは ja 固定 + const language = 'ja' + + const article = useMemo ( + () => getHelpArticle (identifier), + [identifier]) + + const query = new URLSearchParams (location.search) + const requestedEdition = query.get ('edition') + + const languageMeta = article?.languages[language] + + const edition = requestedEdition ?? languageMeta?.currentEdition + + const editions = useMemo (() => getHelpEditions (identifier, language), + [identifier, language]) + + const index = edition ? editions.indexOf (edition) : -1 + const pred = index > 0 ? editions[index - 1] : undefined + const succ = + index >= 0 && index < editions.length - 1 + ? editions[index + 1] + : undefined + + const [loaded, setLoaded] = useState<{ identifier: string + language: string + edition: string + title: string + content: ComponentType | null } | null> (null) + + 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))) + return + + const current = + (loaded?.identifier === identifier + && loaded?.language === language + && loaded?.edition === edition) + ? loaded + : null + + if (current && (!(current.content) || !(current.title))) + return + + const Content = current?.content + + return ( + + + {current?.title || identifier} ヘルプ | {SITE_TITLE} + + + {requestedEdition && ( +
+ {pred + ? ( + + < 古 + ) + : '(最古)'} + + {edition} + + {succ + ? ( + + 新 > + ) + : '(最新)'} +
)} + +
+ {Content ? :
Loading...
} +
+
) +} + + +export default HelpDetailPage diff --git a/frontend/src/pages/help/HelpListPage.tsx b/frontend/src/pages/help/HelpListPage.tsx new file mode 100644 index 0000000..2a3a91d --- /dev/null +++ b/frontend/src/pages/help/HelpListPage.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useState } from 'react' +import { Helmet } from 'react-helmet-async' + +import PrefetchLink from '@/components/PrefetchLink' +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 { dateString, inputClass } from '@/lib/utils' + +import type { FormEvent, FC } from 'react' + +import type { HelpSearchEntry } from '@/lib/help' + + +type SearchConditions = { title: string; text: string } + + +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) + + useEffect (() => { + let active = true + + void getHelpSearchEntries ('ja') + .then (data => { + if (active) + setEntries (data) + }) + .catch (err => { + if (active) + setError (err instanceof Error ? err.message : '取得に失敗しました.') + }) + .finally (() => { + if (active) + setLoading (false) + }) + + return () => { active = false } + }, []) + + const results = useMemo (() => { + const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase () + const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase () + + return entries.filter (entry => ( + (!(titleQuery) || 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]) + + const handleSearch = (ev: FormEvent) => { + ev.preventDefault () + setConditions ({ title, text }) + } + + return ( + + + {`ヘルプ | ${SITE_TITLE}`} + + +
+ ヘルプ +
+ + {({ invalid }) => ( + setTitle (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + setText (e.target.value)} + className={inputClass (invalid)}/>)} + + +
+ +
+
+
+ + {loading + ?

読み込み中...

+ : (error + ?

{error}

+ : ( +
+

{results.length} 件

+ + + + + + + + + {results.map (entry => { + const updatedAt = new Date(entry.edition.replace (/\..*$/, '')) + + return ( + + + + ) + })} + +
タイトル最終更新
+ + {entry.title} + + {dateString (updatedAt, 'hour')}
+ {results.length === 0 &&

該当する記事がありません.

} +
))} +
) +} + + +export default HelpSearchPage diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 948e859..24da3f6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,10 +3,22 @@ import react from '@vitejs/plugin-react' import path from 'path' import { defineConfig } from 'vite' +const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components' }) +const originalTransform = mdxPlugin.transform + +if (typeof originalTransform === 'function') + { + mdxPlugin.transform = + function (code, id) + { + return (/[?&](raw|url)(?:&|$)/.test (id) + ? null + : originalTransform.call (this, code, id)) + } + } -// https://vite.dev/config/ export default defineConfig ({ - plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], + plugins: [mdxPlugin, react ()], resolve: { alias: { '@': path.resolve (__dirname, './src') } }, server: { host: true, port: 5173,